ZenzaWatch HLS Support

ZenzaWatchをHLSに対応させる

Versão de: 15/05/2019. Veja: a última versão.

// ==UserScript==
// @name           ZenzaWatch HLS Support
// @namespace      https://github.com/segabito/
// @description    ZenzaWatchをHLSに対応させる
// @match          *://www.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          *://uad.nicovideo.jp/*
// @match          *://*.nicovideo.jp/smile*
// @match          *://site.nicovideo.jp/*
// @match          *://anime.nicovideo.jp/*
// @exclude        *://ads.nicovideo.jp/*
// @exclude        *://www.upload.nicovideo.jp/*
// @exclude        *://www.nicovideo.jp/watch/*?edit=*
// @exclude        *://www.nicovideo.jp/mylist/*
// @exclude        *://ch.nicovideo.jp/tool/*
// @exclude        *://flapi.nicovideo.jp/*
// @exclude        *://dic.nicovideo.jp/p/*
// @grant          none
// @author         segabito macmoto
// @version        0.0.11
// @noframes
// @run-at         document-start
// ==/UserScript==


const MODULES = `
//import {html, render} from 'https://cdn.jsdelivr.net/npm/[email protected]/lit-html.js';
//import * as _ from 'https://cdn.rawgit.com/lodash/lodash/4.17.4-es/lodash.default.min.js';
//

const ZenzaHLSmodules = {ErrorEvent, MediaError, HTMLDialogElement: window.HTMLDialogElement || HTMLDivElement, DOMException};
`;
// hls.js@latest だと再生が始まらない動画がたまにある。 0.8.9ならok

(() => {
  const PRODUCT = 'ZenzaWatchHLS';
  const monkey = (PRODUCT, {ErrorEvent, MediaError, HTMLDialogElement, DOMException}) => {
    const console = window.console;
    const VER = '0.0.1';
    const PopupMessage = {debug: () =>{}};
    let primaryVideo;

    let util = {fetch: (...args) => { return window.fetch(...args); }};
    console.log(`%c${PRODUCT} v:%s`, 'background: cyan;', VER);

    console.time('ZenzaWatch HLS');


    const DEFAULT_CONFIG = {
      // hls.js 以外のパラメータ
      segment_duration: 4000, // dmc!
      use_native_hls: true,   // SafariなどブラウザがHLS対応だったらそっちを使う
      show_video_label: false, //
      autoAbrEwmaDefaultEstimate: true,
      hls_js_ver: 'latest',

      enable_db_cache: !true,
      cache_expire_time: 6 * 60 * 60 * 1000,

      // 以下、 hls.js 関連

      // なんとなくわかる物
      debug: false, // used by logger
      autoStartLoad: true, // used by stream-controller
      startLevel: -1, // undefined, // used by level-controller
      capLevelOnFPSDrop: false, // used by fps-controller
      capLevelToPlayerSize: false, // used by cap-level-controller
      maxBufferLength: 30, // used by stream-controller
      maxBufferSize: 60 * 1000 * 1000, // used by stream-controller
      maxMaxBufferLength: 600, //600, // used by stream-controller
      minAutoBitrate: 0, // used by hls
      abrEwmaFastVoD: 3, // used by abr-controller
      abrEwmaSlowVoD: 9, // used by abr-controller
      abrEwmaDefaultEstimate: 5e5, // 500 kbps  // used by abr-controller
      abrBandWidthFactor: 0.95, // used by abr-controller
      abrBandWidthUpFactor: 0.7, // used by abr-controller
      abrMaxWithRealBitrate: false, // used by abr-controller
      manifestLoadingTimeOut: 30000, // used by playlist-loader
      manifestLoadingMaxRetry: 3, // used by playlist-loader
      manifestLoadingRetryDelay: 3000, //1000, // used by playlist-loader
      manifestLoadingMaxRetryTimeout: 64000, // used by playlist-loader
      levelLoadingTimeOut: 10000, // used by playlist-loader
      levelLoadingMaxRetry: 4, // used by playlist-loader
      levelLoadingRetryDelay: 3000, //1000, // used by playlist-loader
      levelLoadingMaxRetryTimeout: 64000, // used by playlist-loader
      fragLoadingTimeOut: 20000, // used by fragment-loader
      fragLoadingMaxRetry: 5, //6 // used by fragment-loader
      fragLoadingRetryDelay: 1000, // used by fragment-loader
      fragLoadingMaxRetryTimeout: 64000, // used by fragment-loader

      // よくわからん物
      startPosition: -1, // used by stream-controller
      maxBufferHole: 0.5, // used by stream-controller
      maxSeekHole: 2, // used by stream-controller
      lowBufferWatchdogPeriod: 0.5, // used by stream-controller
      highBufferWatchdogPeriod: 3, // used by stream-controller
      nudgeOffset: 0.1, // used by stream-controller
      nudgeMaxRetry: 3, // used by stream-controller
      maxFragLookUpTolerance: 0.25, // used by stream-controller
      enableWorker: true, // used by demuxer
      enableSoftwareAES: true, // used by decrypter

      startFragPrefetch: false, // used by stream-controller
      fpsDroppedMonitoringPeriod: 5000, // used by fps-controller
      fpsDroppedMonitoringThreshold: 0.2, // used by fps-controller
      appendErrorMaxRetry: 3, // used by buffer-controller
      stretchShortVideoTrack: false, // used by mp4-remuxer
      maxAudioFramesDrift: 1, // used by mp4-remuxer
      forceKeyFrameOnDiscontinuity: true, // used by ts-demuxer
      maxStarvationDelay: 4, // used by abr-controller
      maxLoadingDelay: 4, // used by abr-controller
      emeEnabled: false, // used by eme-controller

      // 生放送関連っぽい物
      abrEwmaFastLive: 3, // used by abr-controller
      abrEwmaSlowLive: 9, // used by abr-controller
      initialLiveManifestSize: 1, // used by stream-controller
      liveSyncDurationCount: 3, // used by stream-controller
      liveMaxLatencyDurationCount: Infinity, // used by stream-controller
      liveSyncDuration: undefined, // used by stream-controller
      liveMaxLatencyDuration: undefined, // used by stream-controller
      liveDurationInfinity: false, // used by buffer-controller
    };
    DEFAULT_CONFIG.clone = (() => {
      return Object.assign({}, DEFAULT_CONFIG);
    });

    const dimport = url => {
      const now = Date.now();
      const callbackName = `dimport_${now}`;
      const loader = `
    import * as module${now} from "${url}";
    window.${callbackName}(module${now});
    `.trim();
      return new Promise((res) => {
        const s = document.createElement('script');
        s.type = 'module';
        s.appendChild(document.createTextNode(loader));
        s.dataset.import = url;
        window[callbackName] = (module) => {
          res(module);
          delete window[callbackName];
        };
        document.head.appendChild(s);
      });
    };

    class Emitter {
      on(name, callback) {
        if (!this._events) { this._events = {}; }
        name = name.toLowerCase();
        if (!this._events[name]) {
          this._events[name] = [];
        }
        this._events[name].push(callback);
      }

      clear(name) {
        if (!this._events) { this._events = {}; }
        if (name) {
          this._events[name] = [];
        } else {
          this._events = {};
        }
      }

      emit(name, ...args) {
        if (!this._events) { this._events = {}; }
        name = name.toLowerCase();
        if (!this._events.hasOwnProperty(name)) { return; }
        const e = this._events[name];
        //const arg = Array.prototype.slice.call(arguments, 1);
        for (let i =0, len = e.length; i < len; i++) {
          //e[i].apply(null, arg);
          e[i](...args);
        }
      }
    }


    const Config = (() => {
      const config = {}, emitter = new Emitter();
      Object.keys(DEFAULT_CONFIG).forEach(key => {
        const storageKey = `ZenzaWatch_video.hls.${key}`;
        if (localStorage[storageKey]) {
          try {
            config[key] = JSON.parse(localStorage[storageKey]);
          } catch(e) {
            console.error(storageKey, localStorage[storageKey]);
            localStorage.removeItem(storageKey);
            config[key] = DEFAULT_CONFIG[key];
          }
        } else {
          config[key] = DEFAULT_CONFIG[key];
        }
      });

      return {
        raw: config,
        get: (key) => {
          return config[key];
        },
        set: (key, value) => {
          if (!DEFAULT_CONFIG.hasOwnProperty(key)) { return; }
          const storageKey = `ZenzaWatch_video.hls.${key}`;
            if (config[key] !== value) {
            config[key] = value;
            localStorage[storageKey] = JSON.stringify(value);
            emitter.emit(`update-${key}`, value);
            emitter.emit(`update`, {key, value});
          }
        },
        on: (...args) => { emitter.on(...args); },
        off: (...args) => { emitter.off(...args); },
        emit: (...args) => { emitter.emit(...args); }
      };
    })();

    const debounce = (func, interval) => {
      let timer;
      const result = (...args) => {
        if (timer) {
          timer = clearTimeout(timer);
        }
        timer = setTimeout(() => {
          func(...args);
        }, interval);
      };
      result.cancel = () => {
        if (timer) { timer = clearTimeout(timer); }
      };
      return result;
    };

    const throttle = (func, interval) => {
      let lastTime = 0;
      let lastArgs = null;
      let timer;
      const result = (...args) => {
        const now = performance.now();
        const timeDiff = now - lastTime;

        if (timeDiff < interval) {
          lastArgs = args;
          if (!timer) {
            timer = setTimeout(() => {
              func.apply(null, lastArgs);
              lastTime = now;
              timer = null;
              lastArgs = null;
            }, interval - timeDiff);
          }
          return;
        }

        if (timer) {
          timer = clearTimeout(timer);
        }
        lastTime = now;
        lastArgs = null;
        func(...args);
      };
      result.cancel = () => {
        if (timer) {
          timer = clearTimeout(timer);
        }
      };
      return result;
    };


    const StorageWorker = function(self) {
      let Config = {
        cache_expire_time: 6 * 60 * 60 * 1000
      };

      class IndexDBStorage {
        static get name() { return 'zenza_hls'; }
        static get ver() { return 4; }
        static get storeNames() { return ['ts-data']; }

        static get expireTime() {
          return Config.cache_expire_time;
        }

        static getInstance() {
          if (this._instance) {
            return this._instance;
          }
          this._instance = new this();
          return this._instance;
        }

        static async init() {
          if (this.db) {
            return Promise.resolve(this.db);
          }
          return new Promise((resolve, reject) => {
            let req = indexedDB.open(this.name, this.ver);
            req.onupgradeneeded = e => {
              let db = e.target.result;

              if(db.objectStoreNames.contains(this.name)) {
                db.deleteObjectStore(this.name);
              }

              let [meta] = this.storeNames;
              let store = db.createObjectStore(meta,
                {keyPath: 'expiresAt', autoIncrement: false});
              store.createIndex('hash', 'hash', {unique: true});
              store.createIndex('videoId', 'videoId', {unique: false});
            };

            req.onsuccess = e => {
              this.db = e.target.result;
              resolve(this.db);
              // this.db.close();
            };

            req.onerror = e => {
              reject(e);
            };

          });
        }

        static close() {
          if (!this.db) {
            return;
          }
          this.db.close();
          this.db = null;
        }

        async getStore({mode} = {mode: 'readwrite'}) {
          return new Promise(async (resolve, reject) => {
            let db = await this.constructor.init();
            let [data] = this.constructor.storeNames;
            // window.console.log('getStore', this.storeName, mode);
            let tx = db.transaction(this.constructor.storeNames, mode);
            tx.oncomplete = resolve;
            tx.onerror = reject;
            return resolve({
              transaction: tx,
              store: tx.objectStore(data)
            });
          });
        }

        async putRecord(store, record) {
          return new Promise((resolve, reject) => {
            let req = store.put(record);
            req.onsuccess = e => {
              resolve(e.target.result);
            };
            req.onerror = e => {
              reject(null);
            };
          });
        }

        async getRecord(store, key, {index, timeout}) {
          return new Promise((resolve, reject) => {
            let req =
              index ?
                store.index(index).get(key) : store.get(key);
            req.onsuccess = e => {
              resolve(e.target.result);
            };
            req.onerror = e => {
              reject(null);
            };
            if (timeout) {
              setTimeout(() => {
                reject(`timeout: key${key}`);
              }, timeout);
            }
          });
        }

        async deleteRecord(store, key, {index}) {
          return new Promise((resolve, reject) => {
            let deleted = 0;
            let range = IDBKeyRange.only(key);
            let req =
              index ?
                store.index(index).openCursor(range) : store.openCursor(range);
            req.onsuccess = e =>  {
              let result = e.target.result;
              if (!result) {
                return resolve(deleted > 0);
              }
              result.delete();
              deleted++;
              result.continue();
            };
            req.onerror = e => {
              reject(null);
            };
          });
        }

        async load({hash}) {
          try {
            let {store} =
              await this.getStore({mode: 'readonly'});
            let meta = await this.getRecord(store, hash,
              {index: 'hash', timeout: 3000});
            // this.constructor.close();
            if (!meta) {
              return null;
            }
            return meta;
          } catch(e) {
            console.warn('exeption', e);
            return null;
          }
        }

        async save({hash, videoId, meta}, buffer) {
          let now = Date.now();
          let expiresAt = now + this.constructor.expireTime;
          let record = {
            expiresAt,
            hash,
            videoId,
            expireDate: new Date(expiresAt).toLocaleString(),
            meta,
            updatedAt: now,
            buffer
          };
          // console.log('save', record);
          let {transaction, store} = await this.getStore();
          try {
            await this.deleteRecord(store, hash, {index: 'hash', record});
            let result = await this.putRecord(store, record);
            this.constructor.close();
            return result;
          } catch (e) {
            console.error('save fail', e);
            transaction.abort();
            return false;
          }
        }

        async gc() {
          if (this.isBusy) {
            return;
          }
          let now = Date.now();
          let {store} = await this.getStore();
          this.isBusy = true;
          let deleted = 0;
          let timekey = `storage gc:${new Date().toLocaleString()}`;
          console.time(timekey);
          return new Promise((resolve, reject) => {
            let range = IDBKeyRange.upperBound(now);
            let req = store.delete(range);
            req.onsuccess = e => {
              this.isBusy = false;
              this.constructor.close();
              console.timeEnd(timekey);
              resolve();
            };
            req.onerror = e => {
              reject(e);
              this.isBusy = false;
            };
          }).catch((e) => {
            console.error('gc fail', e);
            store.clear();
          });
        }

        async clear() {
          console.time('storage clear');
          let {store} = await this.getStore();
          return new Promise((resolve, reject) => {
            let req = store.clear();
            req.onsuccess = e => {
              console.timeEnd('storage clear');
              resolve();
            };
            req.onerror = e => {
              console.timeEnd('storage clear');
              reject(e);
            };
          });
        }
      }

      const Storage = {
        async save({hash, videoId, meta}, buffer) {
          return IndexDBStorage.getInstance().save({hash, videoId, meta}, buffer);
        },
        async load(...args) {
          try {
            let result = await IndexDBStorage.getInstance().load(...args);
            // console.log('Storage load result', result);
            if (!result) {
              return null;
            }
            let buffer = result.buffer;
            return {meta: result.meta, buffer};
          } catch(e) {
            console.warn('Storage load fail', e);
            return null;
          }
        },
        async gc() {
          if (navigator && navigator.locks) {
            return await navigator.locks.request('ZenzaHLS_GC', {ifAvailable: true}, async (lock) => {
              if (!lock) {
                return;
              }
              await IndexDBStorage.getInstance().gc();
              await new Promise(r => setTimeout(r, 5000));
            });
          } else {
            return IndexDBStorage.getInstance().gc();
          }
        },
        async clear() {
          return IndexDBStorage.getInstance().clear();
        }
      };

      self.onmessage = async (e) => {
        let result;
        let data = e.data.data;
        let id = e.data.id;
        let status = 'ok';
        try {
          switch (e.data.command) {
            case 'config':
              Object.assign(Config, e.data);
              return self.postMessage({id, result: 'ok'});
            case 'save':
              // let {hash, videoId, meta} = data;
              result = await Storage.save(data, e.data.buffer);
              return self.postMessage({id, result});
            case 'load':
              result = await Storage.load(data);
              if (!result) {
                return self.postMessage({id, status, result: null}, []);
              }
              return self.postMessage({id, status, result: result.meta, buffer: result.buffer}, [result.buffer]);
            case 'gc':
              Storage.gc();
              return self.postMessage({id, status, result: null});
            case 'clear':
              result = await Storage.clear();
              return self.postMessage({id, status, result});
          }
        } catch (e) {
          status = 'fail';
          return self.postMessage({id, status, result: e});
        }
      };
    };

    const createWebWorker = (func, type = '') => {
      let src = func.toString().replace(/^function.*?{/, '').replace(/}$/, '');

      let blob = new Blob([src], {type: 'text/javascript'});
      let url = URL.createObjectURL(blob);

      if (type === 'SharedWorker') {
        return new SharedWorker(url);
      }
      return new Worker(url);
    };

    const Storage = {
      request: {},
      worker: null,
      onMessage: function(e) {
        let id = e.data.id;
        let request = this.request[id];
        if (!request) {
          window.console.warn('unkwnown request id', id);
          return;
        }
        delete this.request[id];
        switch (request.command) {
          case 'load':
            if (e.data.result) {
              return request.resolve([e.data.result, e.data.buffer]);
            } else {
              return request.resolve([null, null]);
            }
          default:
            return request.resolve(e.data.result);
        }
      },
      getId: function() {
        return `id:${Math.random()}-${performance.now()}`;
      },
      sendRequest: async function(command, data, buffer = null) {
        if (window.Prototype) {
          return Promise.resolve(null);
        }
        let id = this.getId();
        // window.console.info('sendrequest', command, data, buffer);
        return new Promise(resolve => {
          this.request[id] = {id, command, resolve};
          if (buffer) {
            this.worker.postMessage({id, command, data, buffer}, [buffer]);
          } else {
            this.worker.postMessage({id, command, data});
          }
        });
      },
      setConfig: async function(config) {
        return this.sendRequest('config', config);
      },
      save: async function({hash, videoId, meta}, buffer) {
        return this.sendRequest('save', {hash, videoId, meta}, buffer);
      },
      load: async function({hash}) {
        return await this.sendRequest('load', {hash});
      },
      gc: function() {
        if (Config.get('enable_db_cache') && !window.Prototype) {
          return this.sendRequest('gc', {});
        } else {
          return Promise.resolve();
        }
      },
      clear: async function() {
        return this.sendRequest('clear', {});
      }
    };
    Storage.worker = createWebWorker(StorageWorker);
    Storage.worker.addEventListener('message', Storage.onMessage.bind(Storage));
    Storage.setConfig({cache_expire_time: Config.get('cache_expire_time')});
    Storage.gc = debounce(Storage.gc.bind(Storage), 10 * 1000);


    const ZenzaVideoElement = (({Hls, throttle}) => {
      // TODO: ニコニコ動画の仕様に依存する部分を切り離して、もう少し汎用的にする

      const PLAYER_MODE = {
        HLS_JS: 'HLS-JS',
        HLS_NATIVE: 'HLS-N',
        DEFAULT: 'N'
      };

      const HLS_ERROR_CODE = {
        ABORT:   MediaError.MEDIA_ERR_ABORTED           + 1000,
        NETWORK: MediaError.MEDIA_ERR_NETWORK           + 1000,
        DECODE:  MediaError.MEDIA_ERR_DECODE            + 1000,
        UNKNOWN: MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED + 1000
      };

      // readonly の Event.target を無理やり書き換える (意味ないかも)
      const overrideTarget = (event, target) => {
        return event;
        const p = new Proxy(event, {
          get: function(t, name){
            if (name === 'target') {
              return target;
            }
            return t[name];
          }
        });
        return p;
      };

      let FragmentLoaderClass;
      const createFragmentLoader = (Hls) => {

        const xhrSetup = function(xhr, url) {
          //xhr.timeout = config.timeout + 1000;
          //xhr.setRequestHeader('Cache-Control', 'force-cache');
          xhr.ontimeout = () => {
            window.console.log('xhr timeout', xhr, url);
          };
          xhr.open('GET', url, true);
        };


        // hls.config.fLoader にはクラスのインスタンスではなく定義を渡す
        // loaderはexportされていないため、Hls.DefaultConfig.loader経由で継承する
        // @see https://github.com/video-dev/hls.js/blob/master/docs/API.md#loader
        let lastFragLoadingTime = 0;
        let BLOCK_INTERVAL = 3000;
        class FragmentLoader extends Hls.DefaultConfig.loader {

          static frag2hash(fragment) {
            const url = fragment.url;
            const levels = this.levels;

            let [path, videoId, level, sn] =
                /\/nicovideo-([a-z0-9]+)_.*\/([\d]+)\/ts\/([\d]+)\.ts/.exec(url);
            let rel = '';
            if (fragment.levelkey && fragment.levelkey.reluri) {
              let m = /h=(.*?)&/.exec(fragment.levelkey.reluri);
              rel = m ? `-${m[1]}` : '';
            }
            if (levels && levels[fragment.level]) {
              let {width, height, bitrate, attrs} = levels[fragment.level];
              let fps = attrs['FRAME-RATE'] ? `${attrs['FRAME-RATE']}fps` : '(unknown)fps';
              return {hash: `${videoId}-${width}x${height}-${bitrate}bps-${fps}-${sn}${rel}`, videoId};
            }
            return {hash: `${videoId}-${fragment.level}-${sn}${rel}`, videoId};
          }

          constructor(config) {
            super(config);
            this._config = config;
            this._isAborted = false;
            this._isDestroyed = false;
//            this.xhrSetup = (xhr, url) => {
//              xhr.open('GET', url, true);
//              xhr.setRequestHeader('Access-Control-Request-Headers', 'Content-Length');
//              window.console.log('xhrSetup', xhr, url);
//            };
            Storage.gc();
          }

          /***
           *
           * @param {{url: string, frag: fragment, responseType: string, progressData: boolean}} context
           * @param config
           * @param {{onSuccess: function, onError: function, onTimout: function, onProgress: function}} callbacks
           * @returns {*}
           */
          async load(context, config, callbacks) {
            if (!context.frag || !/\.ts/.test(context.url)) {
              window.console.info('unknown context', context.url, context);
              return super.load(context, config, callbacks);
            }

            const frag = context.frag;
            const level = frag.level;
            const storage = this._storage;

            const {hash, videoId} = this.constructor.frag2hash(frag);

            const {onSuccess, onError} = callbacks;
            if (!context.rangeStart && !context.rangeEnd) {

              callbacks.onSuccess = async function(resp, stats, context, details = null) {
                let status = details instanceof XMLHttpRequest ? details.status : 200;

                if (status === 206
               //     buffer.byteLength !== contentLength
                ) {
                  console.warn('CONTENT LENGTH MISMATCH!', buffer.byteLength, frag.loaded);//, contentLength);
                } else if (Config.get('enable_db_cache') && !window.Prototype) {
                  let buffer = resp.data.slice();
                  frag.hasCache = true;
                  Storage.save({
                    hash,
                    videoId,
                    meta: {
                      contentLength: context.frag.loaded,
                      sn: context.frag.sn,
                      resp: {
                        url: resp.url
                      },
                      level,
                      total: context.frag.loaded,
                      stats,
                      url: context.url
                    }
                  }, buffer);
                }

                onSuccess(resp, stats, context, details);
              };

              // prototype.js のあるページでは動かないどころかブラクラ化する
              if (Config.get('enable_db_cache') && !window.Prototype) {
                // console.log('***load', hash, Config.get('enable_db_cache'),window.Prototype);
                let [meta, buffer] = await Storage.load({hash});
                // console.log('cache?', !!meta, hash);
                if (meta) {
                  return callbacks.onSuccess(
                    {url: meta.url, data: buffer }, meta.stats, context, null);
                }
              }
            }

            callbacks.onError = (resp /* = {code, text} */, context, xhr) => {
              if (this._isAborted) {
                console.warn('error after aborted', resp, context);
                onError({code: 0, text: `error after aborted ${resp.code}: ${resp.text}`}, context, xhr);
                return;
              }
              onError(resp, context, xhr);
            };

            return this._load(context, config, callbacks, this._config.fragLoadingMaxRetry);
          }

          /**
           * バッドノウハウの塊
           * シーク連打などで短時間に多量のアクセスがあると弾かれるようになるため、
           * クライアント側でウェイトをかける。
           */
          _load(context, config, callbacks, maxRetry = 3) {
            if (this._isDestroyed) {
              return;
            }
            const now = performance.now();
            const timeDiff = now - lastFragLoadingTime;

            const sn = context.frag.sn; // ts の番号が入ってる

            if (!this._isBoostTime(sn) && timeDiff < BLOCK_INTERVAL) {
              if (maxRetry > 0) {
                setTimeout(() => {
                    if (this._isAborted) { return; }
                    this._load(context, config, callbacks, maxRetry - 1);
                  },
                  Math.max(
                    this._config.fragLoadingRetryDelay, BLOCK_INTERVAL - timeDiff)
                );
              } else {
                callbacks.onError(
                  {code: 429, text: 'Too Many Requests(blocked)'},
                  context,
                  {status: 429, statusText: ''}
                );
              }
              return;
            }

            lastFragLoadingTime = now;
            super.load(context, config, callbacks);
          }

          abort() {
            this._isAborted = true;
            super.abort();
          }

          destroy() {
            this._isDestroyed = true;
            this._isAborted = true;
            super.destroy();
          }

          // 再生開始直後だけは自粛したくないタイム
          _isBoostTime(sn) {
            return sn <= 3;
          }

        }

        FragmentLoaderClass = FragmentLoader;
        return FragmentLoaderClass;
      };

      let idCounter = 0;

      const parseUrl = url => {
        const a = document.createElement('a');
        a.href = url;
        return a;
      };

      const compareHash = (url1, url2) => {
        console.log('compareHash\n"%s"\n"%s"', parseUrl(url1).search, parseUrl(url2).search);
        return parseUrl(url1).search === parseUrl(url2).search;
      };

      class ZenzaVideoElement extends HTMLElement {
        static get template() {
          return `
            <style>
              .root {
                width: 100%; height: 100%;
                display: contents;
              }
              video {
                width: 100%;
                height: 100%;
              }
              .label {
                position: absolute;
                z-index: 100;
                top: 16px;
                right: 32px;
                padding: 0 4px;
                font-size: 14pt;
                color: #000;
                background: #888;
                border: 1px solid #888;
                font-family: 'Arial Black';
                pointer-events: none;
                mix-blend-mode: luminosity;
                transition: 0.2s opacity, 0.4s box-shadow;
                opacity: 0.5;
              }

              .label:empty {
                opacity: 0 !important;
                transition: none;
              }

              .is-playing .label {
                opacity: 0;
              }

              :host([show-video-label="on"]) .root .label:not(:empty) {
                opacity: 1 !important;
              }

              .label.blink {
                transform: rotateX(360deg);
                transition: 0.4s transform;
              }

            </style>
            <div class="root">
              <video></video>
              <div class="label"></div>
            </div>
          `;
        }

        constructor() {
          super();

          this._src = '';
          this._hls = null;
          this._hlsConfig = {};
          this._videoCount = 0;
          this._id = `id:${idCounter++}`;

          this._playerMode = PLAYER_MODE.DEFAULT;
          this._eventWrapperMap = new Map();

          const shadow = this._shadow = this.attachShadow({mode: 'open'});
          shadow.innerHTML = this.constructor.template;

          const root = this._root = shadow.querySelector('.root');
          const video = this._video = root.querySelector('video');
          this._label = root.querySelector('.label');

          video.addEventListener('playing', () => {
            root.classList.add('is-playing');
            this.label = `${this.playerMode}: ${this._video.videoWidth}x${this._video.videoHeight}`;
          });

          video.addEventListener('pause', () => {
            root.classList.remove('is-playing');
          });

          this._throttledCurrentTime = throttle(sec => {
            this._isSeeking = false;
            this._video.currentTime = sec;
          }, 500);

          this._resetPlayingStatus();

          this._bridgeProps(video, [
            'autoplay',
            'buffered',
            'crossOrigin',
            'controls',
            'controlslist',
            'currentSrc',
            //'currentTime',
            'defaultMuted',
            'defaultPlaybackRate',
            'duration',
            'ended',
            'loop',
            'muted',
            'networkState',
            'paused',
            'playbackRate',
            'played',
            'playsinline',
            'poster',
            'preload',
            'readyState',
            'seekable',
            //'seeking',
            'videoHeight',
            'videoWidth',
            'volume',

            'tagName',

            'fastSeek',
            'getVideoPlaybackQuality',
            'pause',
            //'play',
            'unload',
            'requestPictureInPicture'
          ]);
        }

        play() {
          if (this._isStalledBy403) {
            return Promise.reject(new DOMException('SessionClosedError'));
          }
          return this._video.play().catch(e => {
            //if (this._isForbidden403) {
            //  return Promise.reject(new DOMException('SessionClosedError'));
            //}
            return Promise.reject(e);
          });
        }

        get shadow() {
          return this._shadow;
        }

        get drawableElement() {
          // 使用例
          // ctx.drawImage(video.drawableElement || video, 0, 0);
          return this._video;
        }

        get currentTime() {
          if (this._isSeeking) {
            return this._seekingTime;
          }
          return this._video.currentTime;
        }

        set currentTime(v) {
          if (this.playerMode === PLAYER_MODE.HLS_JS &&
            this._hlsConfig.autoStartLoad === false) {
            this._hls.startLoad(v);
            return;
          }

          this._seekingTime = v;
          if (this.isInBuffer(v)) {
            // シーク先がバッファ内にある時は即反映
            this._isSeeking = false;
            this._throttledCurrentTime.cancel();
            this._video.currentTime = v;
          } else {
            // シーク連打時のネットワークアクセス抑制
            this._isSeeking = true;
            this._throttledCurrentTime(v);
            this.dispatchEvent(new Event('seeking'));
          }
        }

        isInBuffer(sec) {
          if (sec < this.currentTime) { return true; }
          const range = this.buffered;
          if (!range || !range.length) {
            return false;
          }
          try {
            for (let i = 0, len = range.length; i < len; i++) {
              const start = range.start(i);
              const end   = range.end(i);
              if (start <= sec && end >= sec) {
                return true;
              }
            }
          } catch(e) {
            console.error(e);
          }
          return false;
        }

        get seeking() {
          return this._isSeeking || this._video.seeking;
        }

        get src() {
          return this._src;
        }

        set src(v) {
          this._videoCount++;
          this._resetPlayingStatus();
          if (v === '' || v === '//') {
            this._src = '';
            this._destroyHLSJS();
            this._video.src = '';
            this.playerMode = PLAYER_MODE.DEFAULT;
            return;
          }

          if (/\.m3u8(|\?.*?)$/.test(v)) {
            if (this._useNativeHLS) {
              this._src = v;
              this.playerMode = PLAYER_MODE.HLS_NATIVE;
              this._video.src = v;
              return;
            }
            const hls = this._initHLSJS(v);

            this._src = v;
            //hls.loadSource(v);
            //hls.attachMedia(this._video);
            this.playerMode = PLAYER_MODE.HLS_JS;
            return;
          }

          this._src = v;
          this.playerMode = PLAYER_MODE.DEFAULT;
          this._video.src = v;
        }

        canPlayType(type) {
          switch(type) {
            case 'application/x-mpegURL':
            case 'vnd.apple.mpegURL':
              return Hls.isSupported() ? 'maybe' : '';
            default:
              return this._video.canPlayType(type);
          }
        }

        get error() {
          return this._error || this._video.error;
        }

        load() {
          if (this.playerMode === PLAYER_MODE.HLS_JS) {
            return;
          }
          this._video.load();
        }

        disconnectedCallback() {
          this._destroyHLSJS();
          for (let [func, events] of this._eventWrapperMap.entries()) {
            for (let eventName of Object.keys(events)) {
              this.removeEventListener(eventName, func);
            }
          }
          this._eventWrapperMap.clear();
        }

        setAttribute(attr, value) {
          this._video.setAttribute(attr, value);
          super.setAttribute(attr, value);
        }

        removeAttribute(attr) {
          this._video.removeAttribute(attr);
          super.removeAttribute(attr);
        }

        getAttribute(attr) {
          return this._video.getAttribute(attr);
        }

        addEventListener(eventName, callback, ...options) {
          const map = this._eventWrapperMap.get(callback) || {};

          if (map[eventName]) {
            return;
          }

          const wrapper = (event, ...args) => {
            callback(overrideTarget(event, this), ...args);
          };

          map[eventName] = wrapper;
          this._eventWrapperMap.set(callback, map);
          super.addEventListener(eventName, wrapper, ...options);
          this._video.addEventListener(eventName, wrapper, ...options);
        }

        removeEventListener(eventName, callback) {
          super.removeEventListener(eventName, callback);
          this._video.removeEventListener(eventName, callback);

          const map = this._eventWrapperMap.get(callback);
          if (!map || !map[eventName]) {
            return;
          }

          super.removeEventListener(eventName, map[eventName]);
          this._video.removeEventListener(eventName, map[eventName]);

          delete map[eventName];
          if (Object.keys(map).length < 1) {
            this._eventWrapperMap.delete(callback);
          }
        }

        _bridgeProps(obj, props = []) {
          props.forEach(prop => {
            if (!(prop in obj)) {
              return;
            }
            if (typeof obj[prop] === 'function') {
              this[prop] = (...args) => {
                return obj[prop](...args);
              };
            } else {
              Object.defineProperty(this, prop, {
                get() { return obj[prop]; }, set(v) { obj[prop] = v; }
              });
            }
          });
        }

        _resetPlayingStatus() {
          this._error = null;
          this._isForbidden403 = false;
          this._isStalledBy403 = false;
          this._error429Count = 0;
          this._seekingTime = 0;
          this._isSeeking = false;
          this._isBufferCompleted = false;
          this._throttledCurrentTime.cancel();
        }

        get _useNativeHLS() {
          return !!this._video.canPlayType('application/x-mpegURL') &&
            this.getAttribute('use-native-hls') === 'no';
        }

        set playerMode(v) {
          this._playerMode = v;
          this.label = v;
          super.setAttribute('data-player-mode', v);
        }

        get playerMode() {
          return this._playerMode;
        }

        get label() {
          return this._label.textContent;
        }

        set label(v) {
          if (this._label.textContent === v) { return; }
          this._label.textContent = v;
        }

        get hlsConfig() {
          return this._hlsConfig;
        }

        set hlsConfig(config) {
          this._hlsConfig = config;
          if (this._hls) {
            const hls = this._hls;
            Object.keys(config).forEach(prop => {
              if (prop in hls.config) {
                hls.config[prop] = config[prop];
              }
            });
          }
        }

        get hls() {
          return this._hls;
        }

        _destroyHLSJS() {
          if (this._hls) {
            this._hls.stopLoad();
            this._hls.detachMedia();
            this._hls.destroy();
            if (this._fragmentLoader) {
              delete this._hlsConfig.fLoader;
              delete this._fragmentLoader;
            }
            this._hls = null;
          }
        }

        _initHLSJS(src) {
          this._destroyHLSJS();

          if (!this._hls) {
            //if (this.dataset.usecase !== 'capture') {
            //if (Config.get('enable_db_cache') && !window.Prototype) {
            //  Storage.gc();
            //}
              //setTimeout(() => {Storage.gc(); }, 30000);
            //}
            this._fragmentLoader = createFragmentLoader(Hls);
            this._hlsConfig.fLoader = this._fragmentLoader;
            let hls = new Hls(this._hlsConfig);
            this._hls = hls;

            this._hls.on(Hls.Events.MANIFEST_PARSED,
              this._onHLSJSManifestParsed.bind(this));
            this._hls.on(Hls.Events.LEVEL_LOADED,
              this._onHLSJSLevelLoaded.bind(this));
            this._hls.on(Hls.Events.LEVEL_SWITCHED,
              this._onHLSJSLevelSwitched.bind(this));
            this._hls.on(Hls.Events.ERROR,
              this._onHLSJSError.bind(this));
            this._hls.on(Hls.Events.FRAG_LOADED,
              this._onHLSJSFragLoaded.bind(this));
            this._hls.on(Hls.Events.BUFFER_EOS,
              this._onHLSJSBufferEOS.bind(this));
            this.dispatchEvent(new CustomEvent('init-hls-js', {detail: {hls: hls}}));
            this._hls.on(Hls.Events.MEDIA_ATTACHED, () => {
              this._hls.loadSource(src);
            });
            this._hls.attachMedia(this._video);
          }
          return this._hls;
        }

        _onHLSJSManifestParsed(eventName, data) {
          //console.log('%cHls.Events.MANIFEST_PARSED', 'background: cyan;', this._src);
          this._manifest = data;
          this._levelData = [];
          if (this._fragmentLoader) {
            this._fragmentLoader.levels = data.levels;
          }
          this.dispatchEvent(new Event('loadedmetadata'));
          this.dispatchEvent(new Event('canplay'));
        }

        _onHLSJSLevelLoaded(eventName, data) {
          //console.log('%cHls.Events.LEVEL_LOADED', 'background: cyan;', this._src);//, data);
          this._levelData[data.level] = data.details;
        }

        _onHLSJSLevelSwitched()  {
          const hls = this._hls;
          const level = hls.levels[hls.currentLevel];

          const labelText = `${this.playerMode}: ${this._video.videoWidth}x${this._video.videoHeight}`;
          if (this.label !== labelText) {
            this.label = labelText;
            this._blinkLabel();
          }

          if (hls.levels.length > 1 && level && typeof level.bitrate === 'number') {
            this._hls.config.abrEwmaDefaultEstimate =
              this.hlsConfig.abrEwmaDefaultEstimate = Math.round(level.bitrate * 0.8);

            this.dispatchEvent(new CustomEvent('levelswitched', {detail: {
              level: hls.currentLevel,
              width: this._video.videoWidth,
              height: this._video.videoHeight,
              bitrate: level.bitrate
            }}));
          }
        }

        _blinkLabel() {
          this._label.classList.add('blink');
          setTimeout(() => { this._label.classList.remove('blink'); }, 1000);
        }

        // @see https://github.com/video-dev/hls.js/blob/master/docs/API.md#fifth-step-error-handling
        _onHLSJSError(e, data) {
          let errorUrl = (data.context && data.context.url) ? data.context.url : '';
          errorUrl = (!errorUrl && data.frag && data.frag.url) ? data.frag.url : '';
          // 時間差で前の動画のエラーが飛んできたら無視
          //if (errorUrl && !compareHash(errorUrl, this.src)) {
          //  //return;
          //}

          if (data.fatal) {
            return this._onHLSJSFatalError(e, data);
          }

          console.info('%cHls.Events.ERROR: WARN', 'background: cyan;',
            this._id,
            data.type,
            // data,
            `isForbidden403: ${this._isForbidden403}, isStalledBy403: ${this._isStalledBy403}, isSeeking: ${this._isSeeking}`
          );

          switch (data.type) {
            case Hls.ErrorTypes.NETWORK_ERROR:
              if (this._isBufferCompleted) { return; }
              const code = data.response ? data.response.code : 0;

              if (code === 429) {
                // 短時間でアクセスしすぎると出る
                this._error429Count++;
                // TODO: インターバルを伸ばす? (そしていつ戻す?)
                //this._hls.config.fragLoadingRetryDelay += 500;
              } else if (code === 403) {
                // 403になるとそのセッションは死ぬ
                console.warn('Forbidden 403: %s\n%s', this._id, this.src);
                this._isForbidden403 = true;
                //this._hls.stopLoad();
              }
              break;
            case Hls.ErrorTypes.MEDIA_ERROR:
              if ([Hls.ErrorDetails.BUFFER_STALLED_ERROR,
                   Hls.ErrorDetails.BUFFER_SEEK_OVER_HOLE
                  ].includes(data.details)) {

                // 403でバッファも尽きた時はどうしようもないのでエラー飛ばす
                if (this._isForbidden403) {
                  this._isStalledBy403 = true;
                  const event = new ErrorEvent('error');
                  this._error = {
                    code: HLS_ERROR_CODE.NETWORK,
                    message: `403 Forbidden, ${data.details}`
                  };
                  this.dispatchEvent(overrideTarget(event, this));
                } else {
                 // this.dispatchEvent(new Event('stalled'));
                }
              }
              break;
          }
          return;
        }

        _onHLSJSFatalError(e, data) {
          console.error('%cHls.Events.ERROR: FATAL', 'background: cyan;', this._id,  data);
          let code = 0;
          // サンプルコードではここで自動リカバーしているが、
          // サーバーの障害でエラーが起きてる場合は追加攻撃になりかねないので、やめておく
          switch (data.type) {
            case Hls.ErrorTypes.NETWORK_ERROR:
              code = HLS_ERROR_CODE.NETWORK;
              if (this._isBufferCompleted) { return; }
              //hls.startLoad();
              break;
            case Hls.ErrorTypes.MEDIA_ERROR:
              code = HLS_ERROR_CODE.DECODE;
              //hls.recoverMediaError();
              break;
            default:
              code = HLS_ERROR_CODE.UNKNOWN;
              break;
          }

          const event = new ErrorEvent('error');
          this._error = {
            code: HLS_ERROR_CODE.UNKNOWN,
            message: data.details
          };
          this.dispatchEvent(overrideTarget(event, this));
        }

        _onHLSJSFragLoaded() {
        }

        _onHLSJSBufferEOS() {
          this._isBufferCompleted = true;
          this.dispatchEvent(new CustomEvent('buffercomplete', {detail:
            {src: this._src}
          }));
        }
      }

      if (window.customElements) {
        window.customElements.define('zenza-video', ZenzaVideoElement);
      }
      if (!Hls) {
        const s = document.createElement('script');
        s.onload = () => {
          Hls = window.Hls;
          console.info('hls.js loaded:', window.Hls.version);
        };
        s.src = `https://cdn.jsdelivr.net/npm/hls.js@${Config.get('hls_js_ver')}`;
        // console.info('load hls.js from', s.src);
        document.documentElement.append(s);
      } else {
        console.info('hls.js ready:', window.Hls.version);
      }
      return ZenzaVideoElement;
    })({Hls: window.Hls, throttle});


    const initDebugElements = (config, {html, render}) => {
      if (!HTMLDialogElement || !window.customElements) {
        return;
      }

      class VideoDebugInput extends HTMLElement {
        static get observedAttributes() { return ['value']; }

        static get props() {
          return [
            {name: 'name', type: 'string'},
            {name: 'value', type: 'string'},
          ];
        }

        constructor() {
          super();

          this._props = {};
          this.constructor.props.forEach(({name, type}) => {
            const attr = this.getAttribute(name);
            switch (type) {
              case 'boolean':
                this._props[name] = attr === 'true';
                break;
              case 'number':
                if (!attr) { return; }
                this._props[name] = parseFloat(attr);
                break;
              default:
                this._props[name] = attr;
            }
          });

          this._shadow = this.attachShadow({mode: 'open'});
          render(this.html(this._props), this._shadow);

          this._input = this._shadow.querySelector('input');
          this._root = this._shadow.querySelector('.root, input');
          this._details = this._shadow.querySelector('.details');
          this._detailsText = this._shadow.querySelector('.detailsText');
          const details = (this.getAttribute('details') || '').trim();
          this._details.hidden = !details;
          if (details) {
            this._detailsText.innerHTML = details.replace(/\n/g, '<br>');
          }

          const text = (this.getAttribute('text') || '').trim();
          if (text) {
            this._root.querySelector('.labelText').textContent = text;
          }
        }

        html(props) {
          return html`
            <input name="${props.name}" value="${props.value}">
            `;
        }

        set defaultValue(v) {
          this._defaultValue = v;
          this.setAttribute('default-value', v);
          this._root.classList.toggle('is-changed', this.value !== this.defaultValue);
        }

        set value(v) {
          if (this._props.value === v) { return; }
          this._props.value = v;
          this._input.value = v;
          this.setAttribute('value', v);
          this._input.setAttribute('data-value', v);
          this._root.classList.toggle('is-changed', this.value !== this.defaultValue);
          this.dispatchEvent(new Event('change'));
        }

        get name() {
          return this._props.name || '';
        }

        get value() {
          return this._props.value;
        }

        get defaultValue() {
          return this._defaultValue;
        }

        reset() {
          this.value = this.defaultValue;
        }

        attributeChangedCallback(attrName, oldVal, newVal) {
          if (attrName === 'value' && oldVal !== newVal) {
            this.value = newVal;
          }
        }
      }

      class VideoDebugCheckbox extends VideoDebugInput {
        static get props() {
          return [
            {name: 'name', type: 'string'},
            {name: 'value', type: 'boolean'}
          ];
        }

        constructor() {
          super();

          this._input.checked = this._props.value;
          this._input.addEventListener('change', () => {
            this.value = this._input.checked;
          });
        }

        html(props) {
          return html`
            <style>
              .root {
                display: block;
                position: relative;
                white-space: nowrap;
                padding: 8px 8px 16px;
              }

              .labelText {
                display: inline-block;
                text-align: right;
                font-family: monospace;
                padding: 0 8px;
              }

              .is-changed .labelText {
                font-weight: bolder;
                color: blue;
              }

              input[type=checkbox] {
                transform: scale(2);
                margin-right: 8px;
              }

              label {
                min-width: 300px;
                padding: 8px;
                cursor: pointer;
              }

              label:hover {
              }

              .details {
                padding-left: 16px;
              }
              .summary {
                outline: 0
              }
              .detailsText {
                color: #666;
              }


            </style>
            <div class="root">
              <label>
              <input type="checkbox" name="${props.name}">
                <span class="labelText">${props.name}<content></content></span>
              </label>
              <slot></slot>
              <details class="details">
                <summary class="summary">詳細</summary>
                <div class="detailsText"></div>
              </details>

            </div>
            `;
        }

        set value(v) {
          v = typeof v === 'boolean' ? v : (v === 'true');
          if (this._props.value === v) { return; }
          this._props.value = v;
          this._input.checked = v;
          this.setAttribute('value', v);
          this._root.classList.toggle('is-changed', v !== this.defaultValue);
          this.dispatchEvent(new Event('change'));
        }

        get value() {
          return this._props.value;
        }
      }
      window.customElements.define('video-debug-checkbox', VideoDebugCheckbox);

      class VideoDebugSlider extends VideoDebugInput {
        static get props() {
          return [
            {name: 'name', type: 'string'},
            {name: 'value', type: 'number'},
            {name: 'min', type: 'number'},
            {name: 'max', type: 'number'},
            {name: 'step', type: 'number'}
          ];
        }

        constructor() {
          super();

          this._input.addEventListener('input', () => {
            this.value = this._input.value;
          });
        }

        html(props) {
          return html`
            <style>
              .root {
                display: block;
                position: relative;
                white-space: nowrap;
                padding: 0 8px 16px;
              }
              .labelText {
                display: inline-block;
                text-align: right;
                font-family: monospace;
                padding: 0 16px;
              }

              .is-changed .labelText {
                font-weight: bolder;
                color: blue;
              }

              input[type=range] {
                position: relative;
                -webkit-appearance: none;
                min-width: 400px;
                vertical-align: baseline;
              }

              input[type=range]:focus,
              input[type=range]:active {
                outline: none;
              }

              input[type=range]::after {
                content: attr(data-value);
                position: absolute;
                bottom: 20px;
                right: 4px;
                transform: scale(1.2);
              }

              input[type=range]::-webkit-slider-thumb {
                -webkit-appearance: none;
                appearance: none;
                pointer-events: none;
                position: relative;
                border: none;
                width: 16px;
                height: 16px;
                display: block;
                background-color: #262626;
                border-radius: 50%;
                -webkit-border-radius: 50%;
              }
              input[type=range]:active::-webkit-slider-thumb {
                box-shadow: 0 0 4px #888;
              }

              label {
                display: inline-block;
                min-width: 300px;
                cursor: pointer;
              }

              label:hover {
              }

              .details {
                padding-left: 16px;
              }
              .summary {
                outline: 0
              }
              .detailsText {
                color: #666;
              }

            </style>
            <div class="root">
            <label>
              <input type="range"
                min="${props.min}"
                max="${props.max}"
                step="${props.step}"
                value="${props.value}"
              >
              <span class="labelText">${props.name}<content></content></span>

            </label>
            <details class="details">
              <summary class="summary">詳細</summary>
              <div class="detailsText"></div>
            </details>
            </div>`;
        }

        set value(v) {
          v = parseFloat(v);
          const diff = Math.abs(this._props.value - v);
          if (diff <= 0.01) { return; }
          this._props.value = v;
          this._input.value = v;
          this.setAttribute('value', v);
          this._input.setAttribute('data-value', v.toLocaleString());
          this._root.classList.toggle('is-changed', this.value !== this.defaultValue);
          this.dispatchEvent(new Event('change'));
        }

        get value() {
          return this._props.value;
        }

      }
      window.customElements.define('video-debug-slider', VideoDebugSlider);

      class VideoDebugDialog extends HTMLElement {
        static get observedAttributes() {
          return [
            'enable_db_cache',
            'cache_expire_time',
            'debug',
            'startLevel',
            'capLevelOnFPSDrop',
            'capLevelToPlayerSize',
            'maxBufferLength',
            'maxBufferSize',
            'maxMaxBufferLength',
            'minAutoBitrate',
            'abrEwmaFastVoD',
            'abrEwmaSlowVoD',
            'abrEwmaDefaultEstimate',
            'abrBandWidthFactor',
            'abrBandWidthUpFactor',
            'abrMaxWithRealBitrate'
          ];
        }

        constructor() {
          super();
          this._hlsConfig = {};
          this._elm = {};
        }

        set hlsConfig(v) {
          this._hlsConfig = v;
          Object.keys(v).forEach(key => {
            if (this._elm[key]) {
              this._elm[key].value = v[key];
            }
            //this.setAttribute(key, v[key]);
          });
        }

        get hlsConfig() {
          return this._hlsConfig;
        }

        reset(config) {
          if (!this._shadow) { return; }
          Object.keys(this._elm).forEach(key => {
            if (config && config[key]) {
              this._elm[key].defaultValue = config[key];
            }
            this._elm[key].reset();
          });
        }

        get isOpen() {
          if (!this._root) { return; }
          return !!this._root.open;
        }

        open() {
          if (!this._root) { return; }
          if (!this._root.open) {
            this._root.showModal();
          }
        }

        close() {
          if (!this._root) { return; }
          if (this._root.open) {
            this._root.close();
          }
        }

        toggle() {
          if (!this._root) { return; }
          if (this.isOpen) {
            this.close();
          } else {
            this.open();
          }
        }

        connectedCallback() {
          if (!this._shadow) {
            this._shadow = this.attachShadow({mode: 'open'});
            render(this.html(this._hlsConfig), this._shadow);
            this._root = this._shadow.querySelector('.root');

            Array.from(
              this._shadow.querySelectorAll(
                'video-debug-checkbox, video-debug-slider')).forEach(elm => {
                  this._elm[elm.name] = elm;
                  elm.defaultValue = this.hlsConfig[elm.name];
                  elm.value = this.hlsConfig[elm.name];

                  elm.addEventListener('change', e => {
                    //this.setAttribute(e.target.name, e.target.value);
                    this.dispatchEvent(
                      new CustomEvent('change',
                        {detail: {name: e.target.name, value: e.target.value}})
                    );
                  });

              });
            this._root.addEventListener('click', this._onClick.bind(this));
          }
        }

        setValue(key, value) {
          if (this._elm && this._elm[key]) {
            this._elm[key].value = value;
          }
        }

        getValue(key) {
          if (this._elm && this._elm[key]) {
            return this._elm[key].value;
          }
        }

        _onClick(e) {
          const target = e.target;
          if (target === this._root) {
            this.close();
            return;
          }
          const commandElement = target.closest('[data-command]');
          if (!commandElement) {
            return;
          }
          const command = commandElement.getAttribute('data-command');
          //const param   = commandElement.getAttribute('data-param');
          switch (command) {
            case 'toggle':
              this.toggle();
              break;
            case 'save':
              const config = {};
              Object.keys(this._elm).forEach(key => {
                config[key] = this._elm[key].value;
              });
              this.dispatchEvent(
                new CustomEvent('save', {detail: {config}}));
              break;
            case 'reset':
              this.dispatchEvent(new CustomEvent('reset'));
              break;
            case 'clear-cache':
              if (target.classList.contains('is-busy')) {
                break;
              }
              target.classList.add('is-busy');
              Storage.clear();
              setTimeout(() =>  target.classList.remove('is-busy'), 5000);
              break;
            default:
              return;
          }
          e.preventDefault();
          e.stopPropagation();
        }

        attributeChangedCallback(attrName, oldVal, newVal) {
          if (this._elm[attrName]) {
            this._elm[attrName].value = newVal;
          }
        }
        //adoptedCallback() {}

        html(/* config */) {
          return html`
          <style>
            .root {
              position: fixed;
              width: 640px;
              /*max-height: calc(100vh - 32px);*/
              /*top: 100vh;*/
              z-index: 10000;
              overflow-x: hidden;
              overflow-y: visible;
              /*transform: translate(0, -32px);*/
              background: rgba(240, 240, 240, 0.95);
              color: #000;
              padding: 0;
              border: 0;
              border-radius: 4px;
              transition: 0.2s transform ease, 0.2s box-shadow ease, 0.2s opacity;
              user-select: none;
              opacity: 1;
              box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.8);
              /* mix-blend-mode: hard-light;*/
            }

            .root::backdrop {
              /*
              background:
                linear-gradient(to bottom, rgba(0, 0, 80, 0.5), rgba(0, 0, 0, 0.5));
                */
              background: rgba(0, 0, 0, 0.2);
            }

            a {
              outline: none;
            }

            .dialogInner {
              padding: 0 8px 8px;
            }

            .title {
              padding: 0 8px;
              text-shadow: 2px 2px 2px #ccc;
            }

            .description {
              padding: 0px 8px 8px;
            }

            .buttomContainer {
              text-align: center;
            }

            button[data-command] {
              width: 100px;
              margin: 8px;
              padding: 8px;
              cursor: pointer;
              white-space: nowrap;
            }

            button[data-command="clear-cache"] {
              width: auto;
            }
            button[data-command="clear-cache"]:active,
            button[data-command="clear-cache"].is-busy {
              cursor: wait;
              opacity: 0.5;
            }

            .video-debug-checkbox {
              display: inline-block;
            }

          </style>
          <dialog class="root">
            <div class="dialogInner">
            <h1 class="title">HLS 設定</h1>
            <p class="description">
            設定はリロード後に反映されます。
            詳しいドキュメントは
            <a href="https://github.com/video-dev/hls.js/blob/master/docs/API.md#fine-tuning" target="_blank">こちら</a>
            </p>
            <video-debug-slider
              name="maxBufferLength"
              text="最少バッファ量(秒)"
              details="
最低限バッファを確保する量 (秒数指定)
↓と比較して大きいほうが確保される
          "
              min="0" max="300" step="5"></video-debug-slider>

            <video-debug-slider
              name="maxBufferSize"
              text="最少バッファ量(byte)"
              details="
最低限バッファを確保する量 (バイト数指定)
↑と比較して大きいほうが確保される
          "
              min="0" max="1000000000" step="1000"></video-debug-slider>

            <video-debug-slider
              name="maxMaxBufferLength"
              text="最大バッファ量(秒)"
              details="
バッファを確保する量の上限 (秒数指定)。
あまり大きな値を入れてもブラウザの制限に引っかかるので効果なし。
"
              min="0" max="1200" step="5"></video-debug-slider>

            <video-debug-slider
              name="minAutoBitrate"
              text="下限ビットレート"
              details="
自動画質選択時のビットレート下限。
これより低いビットレートは選択されなくなる。
          "
              min="0" max="6000000" step="100000"></video-debug-slider>

            <video-debug-slider
              name="startLevel"
              details="
自動画質再生時に最初に選択される画質。-1 はオート。
0 からは画質の低い順
"
              min="-1" max="4" step="1"></video-debug-slider>

            <video-debug-slider
              name="abrEwmaDefaultEstimate"
              details="
自動画質再生時に最初に見積もる回線速度。
大きめの値を入れると最初から高画質が選択されやすくなる。
          "
              min="0" max="6000000" step="1000"></video-debug-slider>

            <video-debug-checkbox
              name="autoAbrEwmaDefaultEstimate"
              text="abrEwmaDefaultEstimate の値を自動で調整する"
            ></video-debug-checkbox>


            <video-debug-checkbox
              name="capLevelOnFPSDrop"
              text="フレームレートが落ちたら画質を下げる"
            ></video-debug-checkbox>

            <video-debug-checkbox
              name="capLevelToPlayerSize"
              text="プレイヤーの表示サイズで画質を制限する"
            ></video-debug-checkbox>

            <video-debug-checkbox
              name="show_video_label"
              text="再生中に動画情報を表示"
            ></video-debug-checkbox>

            <video-debug-checkbox
              name="enable_db_cache"
              text="IndexedDBに動画データをキャッシュする(実験中)"
              details="画質ごとにキャッシュが作られるので、画質設定は「自動」以外を推奨"
            >
              <button type="button" data-command="clear-cache">
                キャッシュのクリア
              </button>
            </video-debug-checkbox>

            <video-debug-checkbox
              name="debug"
            ></video-debug-checkbox>

            <div class="buttomContainer">
              <button type="button" data-command="save">設定の保存</button>
              <button type="button" data-command="reset">リセット</button>
              <button type="button" data-command="toggle">閉じる</button>
            </div>
            </div>
          </dialog>`;
        }
      }

      window.customElements.define('video-debug-dialog', VideoDebugDialog);
      return VideoDebugDialog;
    };

    const ZenzaDetector = (() => {
      let isReady = false;
      let ZenzaWatch = null;
      const emitter = new Emitter();

      let initialize = () => {
        initialize = () => {};
        const onBeforeZenzaReady = () => {
          if (ZenzaWatch && ZenzaWatch.debug.isHLSSupported) {
            return;
          }
          ZenzaWatch = window.ZenzaWatch;
          ZenzaWatch.debug.isHLSSupported = true;
          //ZenzaWatch.debug.VideoSession = VideoSession;

          emitter.emit('beforeready', ZenzaWatch);
        };

        const onZenzaReady = () => {
          isReady = true;
          emitter.emit('ready', ZenzaWatch);
        };

        if (window.ZenzaWatch && window.ZenzaWatch.ready) {
          window.console.log('ZenzaWatch is Ready ZenzaHLS');
          onBeforeZenzaReady();
          onZenzaReady();
        } else {
          if (window.ZenzaWatch) {
            onBeforeZenzaReady();
          }
          document.addEventListener('DOMContentLoaded', (z) => {
            if (window.ZenzaWatch && window.ZenzaWatch.ready) {
              onBeforeZenzaReady();
              onZenzaReady();
              return;
            }
            document.body.addEventListener('BeforeZenzaWatchInitialize', (z) => {
              // window.console.log('BeforeZenzaWatchInitialize ZenzaHLS', z);
              onBeforeZenzaReady(z);
            }, {once: true});
            document.body.addEventListener('ZenzaWatchInitialize', () => {
              // window.console.log('ZenzaWatchInitialize ZenzaHLS');
              onZenzaReady();
            }, {once: true});
          });
        }
      };

      const detect = (timing = 'ready') => {
        initialize();
        return new Promise(res => {
          if (isReady || (timing === 'beforeready' && ZenzaWatch)) {
            return res(ZenzaWatch);
          }
          emitter.on(timing, () => {
            res(ZenzaWatch);
          });
        });
      };

      return {
        initialize: initialize,
        detect: detect
      };
    })();

    const initDebug = ({hlsConfig, html, render, ZenzaWatch}) => {
      ZenzaWatch.emitter.once('videoControBar.addonMenuReady', (container, handler) => {
        const div = html`<div class="command controlButton" data-command="toggleHLSDebug">
            <div class="controlButtonInner">hls</div>
          </div>`;
        ZenzaWatch.util.addStyle(`
          .controlButton[data-command=toggleHLSDebug] {
            font-family: Avenir;
          }`, {className: 'ZenzaHLS'});
        container.append(div.getTemplateElement().content);
      });
      ZenzaWatch.emitter.once('videoContextMenu.addonMenuReady.list', (menuContainer) => {
        const li = html`<li class="command" data-command="toggleHLSDebug">HLS設定</li>`;
        menuContainer.appendChild(li.getTemplateElement().content);
      });

      ZenzaWatch.emitter.once('command-toggleHLSDebug', () => {
        initDebugElements(hlsConfig, {html, render});
        const d = document.createElement('video-debug-dialog');
        d.className = 'zen-family';
        d.hlsConfig = hlsConfig;
        d.addEventListener('change', e => {
          if (e.detail.name === 'show_video_label') {
            Config.set(e.detail.name, e.detail.value);
            if (primaryVideo) {
              primaryVideo.setAttribute('show-video-label',
                e.detail.value ? 'on' : 'off');
            }
          } else if (e.detail.name === 'autoAbrEwmaDefaultEstimate') {
            Config.set(e.detail.name, e.detail.value);
          }
        });
        d.addEventListener('save', e => {
          const config = e.detail.config;
          Object.keys(config).forEach(key => {
            if (config[key] !== Config.get(key)) {
              Config.set(key, config[key]);
            }
          });
          if (primaryVideo) {
            primaryVideo.hlsConfig = config;
          }
        });
        d.addEventListener('reset', () => {
          const config = Object.assign({}, DEFAULT_CONFIG);
          d.reset(config);
          if (primaryVideo) {
            primaryVideo.hlsConfig = config;
          }
        });
        Config.on('update-abrEwmaDefaultEstimate', value => {
          if (typeof value !== 'number' || isNaN(value)) { return; }
          d.setValue('abrEwmaDefaultEstimate', value);
        });

        ZenzaWatch.debug.hlsDebugDialog = d;
        document.body.append(d);
        d.open();

        ZenzaWatch.emitter.on('command-toggleHLSDebug', () => {
          ZenzaWatch.debug.hlsDebugDialog.toggle();
        });
      });
    };

    const init = () => {
      console.log('%cinit ZenzaWatch HLS', 'background: cyan');

      const hlsConfig = Object.assign({}, Config.raw);
      Config.on('update', (key, value) => {
        hlsConfig[key] = value;
      });

      let lastLevel = -1;
      const createVideoElement = usecase => {
        if (!window.customElements) {
          return document.createElement('video');
        }
        const video =  document.createElement('zenza-video');
        if (usecase === 'capture') {
          //return null;
          // 静止画キャプチャ用なのにどんどんバッファするのは無駄なので抑える
          video.setAttribute('data-usecase', 'capture');
          video.hlsConfig = {
            autoStartLoad: false,
            // maxBufferSize: 0,
            // maxBufferLength: 5,
            // maxMaxBufferLength: 5,
            manifestLoadingRetryDelay: 5000,
            levelLoadingRetryDelay: 5000,
            fragLoadingRetryDelay: 5000,
            fragLoadingMaxRetry: 3,
            levelLoadingMaxRetry: 1,
            abrEwmaDefaultEstimate:
            ZenzaWatch.debug.hlsConfig.abrEwmaDefaultEstimate,
            startLevel: lastLevel
          };
          video.addEventListener('init-hls-js', ({detail: {hls}}) => {
            hls.autoLevelCapping = lastLevel;
          });
        } else {
          video.setAttribute('show-video-label', Config.get('show_video_label') ? 'on' : 'off');
          video.hlsConfig = hlsConfig;
          video.addEventListener('levelswitched', e => {
            const detail = e.detail;
            //const kbps = Math.round(detail.bitrate * 10 / 1024) / 10;
            //console.log('Hls.Events.LEVEL_SWITCHED level: %s, size: %sx%s, %sKbps',
            //  detail.level,
            //  detail.width,
            //  detail.height,
            //  kbps
            //);
            lastLevel = detail.level;
            if (Config.get('autoAbrEwmaDefaultEstimate')) {
              ZenzaWatch.debug.hlsConfig.abrEwmaDefaultEstimate =
                Math.round(detail.bitrate * 0.8);
            }
          });
          primaryVideo = video;
        }
        video.setAttribute('use-native-hls', Config.get('use_native_hls') ? 'yes' : 'no');
        return video;
      };

      window.ZenzaHLS = {
        createVideoElement
      };

      ZenzaDetector.detect('beforeready').then(ZenzaWatch => {

        ZenzaWatch.debug.hlsConfig = {};
        Object.keys(Config.raw).forEach(key => {
          Object.defineProperty(ZenzaWatch.debug.hlsConfig, key, {
            get() { return Config.get(key); },
            set(v) { Config.set(key, v); }
          });
        });

        ZenzaWatch.debug.createVideoElement = createVideoElement;
        return ZenzaWatch;
      }).then(ZenzaWatch => {
        console.time('dynamic import');

        Promise.all([
          dimport('https://cdn.jsdelivr.net/npm/[email protected]/lit-html.min.js'),
        //  import('https://cdn.rawgit.com/lodash/lodash/4.17.4-es/lodash.default.js')
        ]).then(([{html, render}]) => {
          console.timeEnd('dynamic import');
          initDebug({hlsConfig, html, render, ZenzaWatch});
        });
        console.timeEnd('ZenzaWatch HLS');
      });
    };


    init();
  };

  if (window !== window.top) {
    if (!/^ZenzaWatchHLS_.*_Loader$/.test(window.name)) {
      return;
    }
    if (!/^smile[a-z0-9-]+\.nicovideo\.jp$/.test(location.host)) {
      return;
    }
  }

  if (window && !window.Hls) {
    const hlsjs = document.createElement('script');
    hlsjs.id = 'HLSJS_Loader';
    hlsjs.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
    document.documentElement.append(hlsjs);
  }

  const script = document.createElement('script');
  script.id = 'ZenzaWatchHLSLoader';
  script.setAttribute('charset', 'UTF-8');
  script.appendChild(
    document.createTextNode(`
      ${MODULES}
      (${monkey})('${PRODUCT}', ZenzaHLSmodules);
    ` )
  );
  document.documentElement.append(script);
})();

长期地址
遇到问题?请前往 GitHub 提 Issues,或加Q群1031348184

赞助商

Fishcpy

广告

Rainyun

注册一下就行

Rainyun

一年攒够 12 元