EMBY弹幕插件(dd-danmaku)安装

背景:在群晖套件安装的emby中安装弹幕插件

项目地址:dd-danmaku

测试windows客户端和WEB端都可加载

  1. 打开emby设置,如图点击添加

66f70249d3a01.png

  1. 粘贴以下代码,状态为强制即可
// ==UserScript==
// @name         Emby danmaku extension
// @description  Emby弹幕插件
// @namespace    https://github.com/RyoLee
// @author       RyoLee
// @version      1.32
// @copyright    2022, RyoLee (https://github.com/RyoLee)
// @license      MIT; https://raw.githubusercontent.com/RyoLee/emby-danmaku/master/LICENSE
// @icon         https://github.githubassets.com/pinned-octocat.svg
// @updateURL    https://cdn.jsdelivr.net/gh/RyoLee/emby-danmaku@gh-pages/ede.user.js
// @downloadURL  https://cdn.jsdelivr.net/gh/RyoLee/emby-danmaku@gh-pages/ede.user.js
// @grant        none
// @match        */web/index.html
// @match        */web/
// ==/UserScript==

(async function () {
    'use strict';
    // ------ 用户配置 start ------
    // Danmaku 依赖路径,index.html 引入的和篡改猴环境不用填,依赖已内置,被 CustomCssJS 执行的特殊环境下使用,支持相对绝对网络路径
    // 默认是相对路径等同 https://emby/web/ 和 /system/dashboard-ui/ ,非浏览器客户端必须使用网络路径
    const requireDanmakuPath = 'https://fastly.jsdelivr.net/gh/weizhenye/[email protected]/dist/danmaku.min.js';
    // 跨域代理 cf_worker
    // const corsProxy = 'https://api.9-ch.com/cors/';
    const corsProxy = 'https://ddplay-api.7o7o.cc/cors/';
    // ------ 用户配置 end ------
    // ------ 程序内部使用,请勿更改 start ------
    const openSouceLicense = {
        self: { version: '1.32', name: 'Emby Danmaku Extension(fork)', license: 'MIT License', url: 'https://github.com/chen3861229/dd-danmaku' },
        original: { version: '1.11', name: 'Emby Danmaku Extension', license: 'MIT License', url: 'https://github.com/RyoLee/emby-danmaku' },
        jellyfinFork: { version: '1.45', name: 'Jellyfin Danmaku Extension', license: 'MIT License', url: 'https://github.com/Izumiko/jellyfin-danmaku' },
        danmaku: { version: '2.0.6', name: 'Danmaku', license: 'MIT License', url: 'https://github.com/weizhenye/Danmaku' },
        dandanplayAPI: { version: 'v2', name: '弹弹play API', license: 'MIT License', url: 'https://github.com/kaedei/dandanplay-libraryindex' },
    };
    const apiPrefix = corsProxy + 'https://api.dandanplay.net/api/v2';
    const dandanplayAPI = {
        searchEpisodes: (anime, episode) => `${apiPrefix}/search/episodes?anime=${anime}${episode ? `&episode=${episode}` : ''}`,
        comment: (episodeId, chConvert) => `${apiPrefix}/comment/${episodeId}?withRelated=true&chConvert=${chConvert}`,
        extcomment: (url) => `${apiPrefix}/extcomment?url=${encodeURI(url)}`,
        posterImg: (animeId) => `https://img.dandanplay.net/anime/${animeId}.jpg`,
    };
    const check_interval = 200;
    const LOAD_TYPE = {
        CHECK: 'check',
        INIT: 'init',
        REFRESH: 'refresh',
        RELOAD: 'reload', // 优先走缓存,其余类型走接口
        SEARCH: 'search',
    };
    let isVersionOld = false;
    // htmlVideoPlayerContainer
    let mediaContainerQueryStr = '.graphicContentContainer';
    const notHide = ':not(.hide)';
    const mediaQueryStr = 'video';

    const eleIds = {
        danmakuSwitchBtn: 'danmakuSwitchBtn',
        danmakuCtr: 'danmakuCtr',
        danmakuWrapper: 'danmakuWrapper',
        h5VideoAdapter: 'h5VideoAdapter',
        dialogContainer: 'dialogContainer',
        danmakuSwitchDiv: 'danmakuSwitchDiv',
        danmakuSwitch: 'danmakuSwitch',
        danmakuSearchNameDiv: 'danmakuSearchNameDiv',
        danmakuSearchName: 'danmakuSearchName',
        danmakuSearchEpisode: 'danmakuSearchEpisode',
        danmakuEpisodeFlag: 'danmakuEpisodeFlag',
        danmakuAnimeDiv: 'danmakuAnimeDiv',
        danmakuSwitchEpisode: 'danmakuSwitchEpisode',
        danmakuEpisodeNumDiv: 'danmakuEpisodeNumDiv',
        danmakuEpisodeLoad: 'danmakuEpisodeLoad',
        danmakuRemark: 'danmakuRemark',
        danmakuAnimeSelect: 'danmakuAnimeSelect',
        danmakuEpisodeNumSelect: 'danmakuEpisodeNumSelect',
        searchImg: 'searchImg',
        extConmentSearchDiv: 'extConmentSearchDiv',
        extUrlsDiv: 'extUrlsDiv',
        currentMatchedDiv: 'currentMatchedDiv',
        filteringDanmaku: 'filteringDanmaku',
        danmakuTypeFilterDiv: 'danmakuTypeFilterDiv',
        danmakuTypeFilterSelectName: 'danmakuTypeFilterSelectName',
        danmakuSourceFilterDiv: 'danmakuSourceFilterDiv',
        danmakuSourceFilterSelectName: 'danmakuSourceFilterSelectName',
        danmakuShowSourceDiv: 'danmakuShowSourceDiv',
        danmakuShowSourceSelectName: 'danmakuShowSourceSelectName',
        danmakuEngineDiv: 'danmakuEngineDiv',
        danmakuChConverDiv: 'danmakuChConverDiv',
        danmakuFilterLevelDiv: 'danmakuFilterLevelDiv',
        danmakuHeightRateDiv: 'danmakuHeightRateDiv',
        danmuListDiv: 'danmuListDiv',
        danmuListText: 'danmakuListText',
        filterKeywordsDiv: 'filterKeywordsDiv',
        danmakuSizeDiv: 'danmakuSizeDiv',
        danmakuSizeLabel: 'danmakuSizeLabel',
        danmakuOpacityDiv: 'danmakuOpacityDiv',
        danmakuOpacityLabel: 'danmakuOpacityLabel',
        danmakuSpeedDiv: 'danmakuSpeedDiv',
        danmakuSpeedLabel: 'danmakuSpeedLabel',
        timelineOffsetDiv: 'timelineOffsetDiv',
        timelineOffsetLabel: 'timelineOffsetLabel',
        settingsCtrl: 'settingsCtrl',
        settingsText: 'settingsText',
        settingsImportBtn: 'settingsImportBtn',
        settingReloadBtn: 'settingReloadBtn',
        filterKeywordsEnableId: 'filterKeywordsEnableId',
        filterKeywordsId: 'filterKeywordsId',
        consoleLogCtrl: 'consoleLogCtrl',
        consoleLogText: 'consoleLogText',
        debugCheckbox: 'debugCheckbox',
        debugButton: 'debugButton',
    };
    const embyOffsetBtnStyle = 'margin: 0;padding: 0;';
    // https://fonts.google.com/icons
    const iconKeys = {
        replay_30: 'replay_30',
        replay_10: 'replay_10',
        replay_5: 'replay_5',
        replay: 'replay',
        reset: 'repeat',
        forward_media: 'forward_media', // electron中图标不正确,使用replay反转
        forward_5: 'forward_5',
        forward_10: 'forward_10',
        forward_30: 'forward_30',
        comment: 'comment',
        comments_disabled: 'comments_disabled',
        switch_on: 'toggle_on',
        switch_off: 'toggle_off',
        setting: 'tune',
        search: 'search',
        done: 'done_all',
        done_disabled: 'remove_done',
        more: 'more_horiz',
        close: 'close',
        refresh: 'refresh',
        block: 'block',
        text_format: 'translate',
    };

    // 此id等同于danmakuTabOpts内的弹幕信息的id
    const currentDanmakuInfoContainerId = 'danmakuTab2';
    // 播放界面下方按钮
    const mediaBtnOpts = [
        { id: eleIds.danmakuSwitchBtn, label: '弹幕开关', iconKey: iconKeys.comment, onClick: doDanmakuSwitch },
        { label: '弹幕设置', iconKey: iconKeys.setting, onClick: createDialog },
    ];
    // 菜单tabs
    const danmakuTabOpts = [
        { id: 'danmakuTab0', name: '弹幕设置', buildMethod: buildDanmakuSetting },
        { id: 'danmakuTab1', name: '手动匹配', buildMethod: buildSearchEpisode },
        { id: currentDanmakuInfoContainerId, name: '弹幕信息', buildMethod: buildCurrentDanmakuInfo },
        { id: 'danmakuTab3', name: '弹幕屏蔽', buildMethod: buildDanmakuFilter },
        { id: 'danmakuTab4', name: '关于', buildMethod: buildAbout },
    ];
    // 弹幕类型过滤
    const danmakuTypeFilterOpts = {
        bottom: { id: 'bottom', name: '底部弹幕' },
        top: { id: 'top', name: '顶部弹幕' },
        ltr: { id: 'ltr', name: '从左至右' },
        // rtl: { id: 'rtl', name: '从右至左', hidden: true },
        onlyWhite: { id: 'onlyWhite', name: '彩色弹幕' },
    };
    const danmakuSource = {
        AcFun: { id: 'AcFun', name: 'A站(AcFun)' },
        BiliBili: { id: 'BiliBili', name: 'B站(BiliBili)' },
        Gamer: { id: 'Gamer', name: '巴哈(Gamer)' },
        DanDanPlay: { id: 'DanDanPlay', name: '弹弹(DanDanPlay)' }, // 无弹幕来源的默认值
        '5dm': { id: '5dm', name: 'D站(5dm)' },
        '异世界动漫': { id: '异世界动漫', name: '异世界动漫' },
    };
    const showSource = {
        source: { id: 'source', name: '来源平台' },
        originalUserId: { id: 'originalUserId', name: '来源用户id' },
        cid: { id: 'cid', name: '来源cid' }, // 非弹幕id,唯一性用cuid
    };
    const danmakuEngineOpts = [
        { id: 'canvas', name: 'canvas' },
        { id: 'dom', name: 'dom' },
    ];
    const danmakuChConverOpts = [
        { id: '0', name: '未启用' },
        { id: '1', name: '转换为简体' },
        { id: '2', name: '转换为繁体' },
    ];
    const danmakuFilterLevelOpts = [
        { id: '0', name: '0' },
        { id: '1', name: '1' },
        { id: '2', name: '2' },
        { id: '3', name: '3' },
    ];
    const danmakuHeightRateOpts = [
        { id: '1', name: '100%' },
        { id: '0.75', name: '75%' },
        { id: '0.5', name: '50%' },
        { id: '0.25', name: '25%' },
    ];
    const timelineOffsetBtns = [
        { label: '-30', valueOffset: '-30', iconKey: iconKeys.replay_30,  style: embyOffsetBtnStyle },
        { label: '-10', valueOffset: '-10', iconKey: iconKeys.replay_10,  style: embyOffsetBtnStyle },
        { label: '-5',  valueOffset: '-5',  iconKey: iconKeys.replay_5,   style: embyOffsetBtnStyle },
        { label: '-1',  valueOffset: '-1',  iconKey: iconKeys.replay,     style: embyOffsetBtnStyle },
        { label: '0',   valueOffset: '0',   iconKey: iconKeys.reset,      style: embyOffsetBtnStyle },
        { label: '+1',  valueOffset: '1',   iconKey: iconKeys.replay,     style: embyOffsetBtnStyle + ' transform: rotateY(180deg);' },
        { label: '+5',  valueOffset: '5',   iconKey: iconKeys.forward_5,  style: embyOffsetBtnStyle },
        { label: '+10', valueOffset: '10',  iconKey: iconKeys.forward_10, style: embyOffsetBtnStyle },
        { label: '+30', valueOffset: '30',  iconKey: iconKeys.forward_30, style: embyOffsetBtnStyle },
    ];
    const danmuListOpts = [
        { id: '0', name: '不启用' , onChange: () => [] },
        { id: '1', name: '显示屏中', onChange: (ede) => ede.danmaku._.runningList },
        { id: '2', name: '显示所有', onChange: (ede) => ede.commentsParsed },
        { id: '3', name: '显示加载', onChange: (ede) => ede.danmaku.comments },
        { id: '4', name: '显示被过滤', onChange: (ede) => { // 取差集慢,减轻负担,默认不启用
            return ede.commentsParsed.filter(s => !ede.danmaku.comments.some(t => s.cuid === t.cuid))
        } },
    ];
    const lsKeys = {
        chConvert: { id: 'danmakuChConvert', defaultValue: 1, name: '简繁转换' },
        switch: { id: 'danmakuSwitch', defaultValue: true, name: '弹幕开关' },
        filterLevel: { id: 'danmakuFilterLevel', defaultValue: 0, name: '密度等级' },
        heightRate: { id: 'danmakuHeightRate', defaultValue: 1, name: '高度比例' },
        fontSizeRate: { id: 'danmakuFontSizeRate', defaultValue: 1, name: '大小' },
        fontOpacity: { id: 'danmakuFontOpacity', defaultValue: 1, name: '透明度' },
        speed: { id: 'danmakuBaseSpeed', defaultValue: 1, name: '速度' },
        timelineOffset: { id: 'danmakuTimelineOffset', defaultValue: 0, name: '轴偏秒' },
        typeFilter: { id: 'danmakuTypeFilter', defaultValue: [], name: '屏蔽类型' },
        sourceFilter: { id: 'danmakuSourceFilter', defaultValue: [], name: '屏蔽来源平台' },
        showSource: { id: 'danmakuShowSource', defaultValue: [], name: '显示每条来源' },
        engine: { id: 'danmakuEngine', defaultValue: 'canvas', name: '弹幕引擎' },
        filterKeywords: { id: 'danmakuFilterKeywords', defaultValue: '', name: '屏蔽关键词' },
        filterKeywordsEnable: { id: 'danmakuFilterKeywordsEnable', defaultValue: true, name: '屏蔽关键词启用' },
        danmuList: { id: 'danmakuDanmuList', defaultValue: 0, name: '弹幕列表' },
        consoleLogEnable: { id: 'danmakuConsoleLogEnable', defaultValue: false, name: '控制台日志' },
    };
    // emby ui class
    const embyLabelClass = 'inputLabel';
    const embyInputClass = 'emby-input emby-input-smaller';
    const embyIconButtonClass = 'flex-shrink-zero paper-icon-button-light';
    const embySelectWrapperClass = 'emby-select-wrapper emby-select-wrapper-smaller';
    const embyCheckboxListClass = 'featureList'; // 'checkboxList'
    const embyFieldDescClass = 'fieldDescription';
    const embyTabsMenuClass = 'headerMiddle headerSection sectionTabs headerMiddle-withSectionTabs';
    const embyTabsDivClass1 = 'tabs-viewmenubar tabs-viewmenubar-backgroundcontainer focusable scrollX hiddenScrollX smoothScrollX scrollFrameX emby-tabs';
    const embyTabsDivClass2 = 'tabs-viewmenubar-slider emby-tabs-slider padded-left padded-right nohoverfocus scrollSliderX';
    const embyTabsButtonClass = 'emby-button secondaryText emby-tab-button main-tab-button';
    const embyCheckboxListStyle = 'display: flex;flex-wrap: wrap;';
    const embySliderListStyle = 'display: flex;flex-direction: column;justify-content: center;align-items: center;'; // 容器内元素垂直排列,水平居中 
    const embySliderStyle = 'display: flex; align-items: center; gap: 1em; margin-bottom: 0.3em;'; // 容器内元素横向并排,垂直居中
  
    // ------ 程序内部使用,请勿更改 end ------

    // ------ require start ------
    let skipInnerModule = false;
    try {
        throw new Error();
    } catch(e) {
        const stackTrace = e.stack;
        skipInnerModule = !!stackTrace && stackTrace.includes('CustomCssJS');
        // console.log('ignore this not error, callee:', e);
    }
    if (!skipInnerModule) {
    /* eslint-disable */
    /* https://cdn.jsdelivr.net/npm/[email protected]/dist/danmaku.min.js */
    // prettier-ignore
    !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Danmaku=e()}(this,(function(){"use strict";var t=function(){if("undefined"==typeof document)return"transform";for(var t=["oTransform","msTransform","mozTransform","webkitTransform","transform"],e=document.createElement("div").style,i=0;i<t.length;i++)if(t[i]in e)return t[i];return"transform"}();function e(t){var e=document.createElement("div");if(e.style.cssText="position:absolute;","function"==typeof t.render){var i=t.render();if(i instanceof HTMLElement)return e.appendChild(i),e}if(e.textContent=t.text,t.style)for(var n in t.style)e.style[n]=t.style[n];return e}var i={name:"dom",init:function(){var t=document.createElement("div");return t.style.cssText="overflow:hidden;white-space:nowrap;transform:translateZ(0);",t},clear:function(t){for(var e=t.lastChild;e;)t.removeChild(e),e=t.lastChild},resize:function(t,e,i){t.style.width=e+"px",t.style.height=i+"px"},framing:function(){},setup:function(t,i){var n=document.createDocumentFragment(),s=0,r=null;for(s=0;s<i.length;s++)(r=i[s]).node=r.node||e(r),n.appendChild(r.node);for(i.length&&t.appendChild(n),s=0;s<i.length;s++)(r=i[s]).width=r.width||r.node.offsetWidth,r.height=r.height||r.node.offsetHeight},render:function(e,i){i.node.style[t]="translate("+i.x+"px,"+i.y+"px)"},remove:function(t,e){t.removeChild(e.node),this.media||(e.node=null)}},n="undefined"!=typeof window&&window.devicePixelRatio||1,s=Object.create(null);function r(t,e){if("function"==typeof t.render){var i=t.render();if(i instanceof HTMLCanvasElement)return t.width=i.width,t.height=i.height,i}var r=document.createElement("canvas"),h=r.getContext("2d"),o=t.style||{};o.font=o.font||"10px sans-serif",o.textBaseline=o.textBaseline||"bottom";var a=1*o.lineWidth;for(var d in a=a>0&&a!==1/0?Math.ceil(a):1*!!o.strokeStyle,h.font=o.font,t.width=t.width||Math.max(1,Math.ceil(h.measureText(t.text).width)+2*a),t.height=t.height||Math.ceil(function(t,e){if(s[t])return s[t];var i=12,n=t.match(/(\d+(?:\.\d+)?)(px|%|em|rem)(?:\s*\/\s*(\d+(?:\.\d+)?)(px|%|em|rem)?)?/);if(n){var r=1*n[1]||10,h=n[2],o=1*n[3]||1.2,a=n[4];"%"===h&&(r*=e.container/100),"em"===h&&(r*=e.container),"rem"===h&&(r*=e.root),"px"===a&&(i=o),"%"===a&&(i=r*o/100),"em"===a&&(i=r*o),"rem"===a&&(i=e.root*o),void 0===a&&(i=r*o)}return s[t]=i,i}(o.font,e))+2*a,r.width=t.width*n,r.height=t.height*n,h.scale(n,n),o)h[d]=o[d];var u=0;switch(o.textBaseline){case"top":case"hanging":u=a;break;case"middle":u=t.height>>1;break;default:u=t.height-a}return o.strokeStyle&&h.strokeText(t.text,a,u),h.fillText(t.text,a,u),r}function h(t){return 1*window.getComputedStyle(t,null).getPropertyValue("font-size").match(/(.+)px/)[1]}var o={name:"canvas",init:function(t){var e=document.createElement("canvas");return e.context=e.getContext("2d"),e._fontSize={root:h(document.getElementsByTagName("html")[0]),container:h(t)},e},clear:function(t,e){t.context.clearRect(0,0,t.width,t.height);for(var i=0;i<e.length;i++)e[i].canvas=null},resize:function(t,e,i){t.width=e*n,t.height=i*n,t.style.width=e+"px",t.style.height=i+"px"},framing:function(t){t.context.clearRect(0,0,t.width,t.height)},setup:function(t,e){for(var i=0;i<e.length;i++){var n=e[i];n.canvas=r(n,t._fontSize)}},render:function(t,e){t.context.drawImage(e.canvas,e.x*n,e.y*n)},remove:function(t,e){e.canvas=null}};function a(t){var e=this,i=this.media?this.media.currentTime:Date.now()/1e3,n=this.media?this.media.playbackRate:1;function s(t,s){if("top"===s.mode||"bottom"===s.mode)return i-t.time<e._.duration;var r=(e._.width+t.width)*(i-t.time)*n/e._.duration;if(t.width>r)return!0;var h=e._.duration+t.time-i,o=e._.width+s.width,a=e.media?s.time:s._utc,d=o*(i-a)*n/e._.duration,u=e._.width-d;return h>e._.duration*u/(e._.width+s.width)}for(var r=this._.space[t.mode],h=0,o=0,a=1;a<r.length;a++){var d=r[a],u=t.height;if("top"!==t.mode&&"bottom"!==t.mode||(u+=d.height),d.range-d.height-r[h].range>=u){o=a;break}s(d,t)&&(h=a)}var m=r[h].range,c={range:m+t.height,time:this.media?t.time:t._utc,width:t.width,height:t.height};return r.splice(h+1,o-h-1,c),"bottom"===t.mode?this._.height-t.height-m%this._.height:m%(this._.height-t.height)}var d="undefined"!=typeof window&&(window.requestAnimationFrame||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,50/3)},u="undefined"!=typeof window&&(window.cancelAnimationFrame||window.mozCancelAnimationFrame||window.webkitCancelAnimationFrame)||clearTimeout;function m(t,e,i){for(var n=0,s=0,r=t.length;s<r-1;)i>=t[n=s+r>>1][e]?s=n:r=n;return t[s]&&i<t[s][e]?s:r}function c(t){return/^(ltr|top|bottom)$/i.test(t)?t.toLowerCase():"rtl"}function l(){var t=9007199254740991;return[{range:0,time:-t,width:t,height:0},{range:t,time:t,width:0,height:0}]}function f(t){t.ltr=l(),t.rtl=l(),t.top=l(),t.bottom=l()}function p(){if(!this._.visible||!this._.paused)return this;if(this._.paused=!1,this.media)for(var t=0;t<this._.runningList.length;t++){var e=this._.runningList[t];e._utc=Date.now()/1e3-(this.media.currentTime-e.time)}var i=this,n=function(t,e,i,n){return function(){t(this._.stage);var s=Date.now()/1e3,r=this.media?this.media.currentTime:s,h=this.media?this.media.playbackRate:1,o=null,d=0,u=0;for(u=this._.runningList.length-1;u>=0;u--)o=this._.runningList[u],r-(d=this.media?o.time:o._utc)>this._.duration&&(n(this._.stage,o),this._.runningList.splice(u,1));for(var m=[];this._.position<this.comments.length&&(o=this.comments[this._.position],!((d=this.media?o.time:o._utc)>=r));)r-d>this._.duration||(this.media&&(o._utc=s-(this.media.currentTime-o.time)),m.push(o)),++this._.position;for(e(this._.stage,m),u=0;u<m.length;u++)(o=m[u]).y=a.call(this,o),this._.runningList.push(o);for(u=0;u<this._.runningList.length;u++){o=this._.runningList[u];var c=(this._.width+o.width)*(s-o._utc)*h/this._.duration;"ltr"===o.mode&&(o.x=c-o.width+.5|0),"rtl"===o.mode&&(o.x=this._.width-c+.5|0),"top"!==o.mode&&"bottom"!==o.mode||(o.x=this._.width-o.width>>1),i(this._.stage,o)}}}(this._.engine.framing.bind(this),this._.engine.setup.bind(this),this._.engine.render.bind(this),this._.engine.remove.bind(this));return this._.requestID=d((function t(){n.call(i),i._.requestID=d(t)})),this}function g(){return!this._.visible||this._.paused||(this._.paused=!0,u(this._.requestID),this._.requestID=0),this}function _(){if(!this.media)return this;this.clear(),f(this._.space);var t=m(this.comments,"time",this.media.currentTime);return this._.position=Math.max(0,t-1),this}function v(t){t.play=p.bind(this),t.pause=g.bind(this),t.seeking=_.bind(this),this.media.addEventListener("play",t.play),this.media.addEventListener("pause",t.pause),this.media.addEventListener("playing",t.play),this.media.addEventListener("waiting",t.pause),this.media.addEventListener("seeking",t.seeking)}function w(t){this.media.removeEventListener("play",t.play),this.media.removeEventListener("pause",t.pause),this.media.removeEventListener("playing",t.play),this.media.removeEventListener("waiting",t.pause),this.media.removeEventListener("seeking",t.seeking),t.play=null,t.pause=null,t.seeking=null}function y(t){this._={},this.container=t.container||document.createElement("div"),this.media=t.media,this._.visible=!0,this.engine=(t.engine||"DOM").toLowerCase(),this._.engine="canvas"===this.engine?o:i,this._.requestID=0,this._.speed=Math.max(0,t.speed)||144,this._.duration=4,this.comments=t.comments||[],this.comments.sort((function(t,e){return t.time-e.time}));for(var e=0;e<this.comments.length;e++)this.comments[e].mode=c(this.comments[e].mode);return this._.runningList=[],this._.position=0,this._.paused=!0,this.media&&(this._.listener={},v.call(this,this._.listener)),this._.stage=this._.engine.init(this.container),this._.stage.style.cssText+="position:relative;pointer-events:none;",this.resize(),this.container.appendChild(this._.stage),this._.space={},f(this._.space),this.media&&this.media.paused||(_.call(this),p.call(this)),this}function x(){if(!this.container)return this;for(var t in g.call(this),this.clear(),this.container.removeChild(this._.stage),this.media&&w.call(this,this._.listener),this)Object.prototype.hasOwnProperty.call(this,t)&&(this[t]=null);return this}var b=["mode","time","text","render","style"];function L(t){if(!t||"[object Object]"!==Object.prototype.toString.call(t))return this;for(var e={},i=0;i<b.length;i++)void 0!==t[b[i]]&&(e[b[i]]=t[b[i]]);if(e.text=(e.text||"").toString(),e.mode=c(e.mode),e._utc=Date.now()/1e3,this.media){var n=0;void 0===e.time?(e.time=this.media.currentTime,n=this._.position):(n=m(this.comments,"time",e.time))<this._.position&&(this._.position+=1),this.comments.splice(n,0,e)}else this.comments.push(e);return this}function T(){return this._.visible?this:(this._.visible=!0,this.media&&this.media.paused||(_.call(this),p.call(this)),this)}function E(){return this._.visible?(g.call(this),this.clear(),this._.visible=!1,this):this}function k(){return this._.engine.clear(this._.stage,this._.runningList),this._.runningList=[],this}function C(){return this._.width=this.container.offsetWidth,this._.height=this.container.offsetHeight,this._.engine.resize(this._.stage,this._.width,this._.height),this._.duration=this._.width/this._.speed,this}var D={get:function(){return this._.speed},set:function(t){return"number"!=typeof t||isNaN(t)||!isFinite(t)||t<=0?this._.speed:(this._.speed=t,this._.width&&(this._.duration=this._.width/t),t)}};function z(t){t&&y.call(this,t)}return z.prototype.destroy=function(){return x.call(this)},z.prototype.emit=function(t){return L.call(this,t)},z.prototype.show=function(){return T.call(this)},z.prototype.hide=function(){return E.call(this)},z.prototype.clear=function(){return k.call(this)},z.prototype.resize=function(){return C.call(this)},Object.defineProperty(z.prototype,"speed",D),z}));
    /* eslint-enable */
    } else {
        window.Danmaku || Emby.importModule(requireDanmakuPath).then(f => {
            console.log(f);
            window.Danmaku = f;
        }).catch(error => {
            console.error(`fail Emby.importModule error:`, error);
        });
    }
    // ------ require end ------

    class EDE {
        constructor() {
            this.chConvert = lsGetItem(lsKeys.chConvert.id);
            this.danmaku = null;
            this.episode_info = null;
            this.ob = null;
            this.loading = false;
            this.danmuCache = {}; // 只包含 conment 未解析
            this.commentsParsed = []; // 包含 conment 和 extConment 解析后全量
            this.extConmentCache = {}; // 只包含 extConment 未解析
            this.destroyIntervalIds = [];
            this.searchDanmakuOpts = {}; // 手动搜索变量
            this.appLogAspect = null; // 应用日志切面
        }
    }

    class AppLogAspect {
        constructor() {
            this.initialized = false;
            this.originalError = console.error;
            this.originalLog = console.log;
            this.originalOnerror = null;
            this.value = '';
            this.listeners = [];
        }
        init() {
            if (this.initialized) { return this; }
            console.error = (...args) => {
                this.originalError.apply(console, args);
                this.value += this.format('ERROR', args);
                this.notifyListeners();
            };
            console.log = (...args) => {
                this.originalLog.apply(console, args);
                this.value += this.format('INFO', args);
                this.notifyListeners();
            };
            this.originalOnerror = window.onerror;
            window.onerror = (...args) => {
                console.error(args);
                if (typeof this.originalOnerror === 'function') {
                    this.originalOnerror(...args);
                }
            };
            this.initialized = true;
            return this;
        }
        destroy(clearValue = true) {
            if (this.initialized) {
                console.error = this.originalError;
                console.log = this.originalLog;
                window.onerror = this.originalOnerror;
                clearValue && (this.value = '');
                this.listeners = [];
                this.initialized = false;
            }
            return this;
        }
        format(level, args) {
            return `[${new Date(Date.now()).toLocaleString()}] [${level}] : `
                + args.map(arg => typeof arg === 'string' ? arg : JSON.stringify(arg)).join(' ') + '\n';
        }
        on(valueChangedCallback) {
            if (valueChangedCallback.toString().includes('console.log') 
                || valueChangedCallback.toString().includes('console.error')) {
                throw new Error('The callback function must not contain console.log or console.error to avoid infinite loops.');
            }
            this.listeners.push(() => valueChangedCallback(this.value));
        }
        notifyListeners() { this.listeners.forEach(listener => listener()); }
        clearValue() { this.value = ''; this.notifyListeners(); }
    }

    function initListener() {
        const _media = document.querySelector(mediaQueryStr);
        // 页面未加载
        if (!_media) {
            window.ede.episode_info && (window.ede.episode_info = null);
            return;
        }
        if (_media.getAttribute('ede_listening')) { return; }
        console.log('正在初始化Listener');
        _media.setAttribute('ede_listening', true);
        _media.addEventListener('play', loadDanmaku);
        console.log('Listener初始化完成');
    }

    function initUI() {
        // 已初始化
        if (document.getElementById(eleIds.danmakuCtr)) { return; }
        console.log('正在初始化UI');

        // ApiClient.isMinServerVersion("4.8.0.00"); 可以精确对比客户端指定版本小于当前版本,但此处暂时不需要
        if (parseFloat(ApiClient.serverVersion()) < 4.8) {
            mediaContainerQueryStr = 'div[data-type="video-osd"]';
            isVersionOld = true;
        }
        mediaContainerQueryStr += notHide;

        // 弹幕按钮父容器 div,延时判断,精确 dom query 时播放器 UI 小概率暂未渲染
        const mediaQueryStr = `${mediaContainerQueryStr} .videoOsdBottom-maincontrols .videoOsdBottom-buttons`;
        waitForElement(mediaQueryStr, (parent) => {
            // 在老客户端上存在右侧按钮,在右侧按钮前添加
            const rightButtons = parent.querySelector('.videoOsdBottom-buttons-right');
            const menubar = document.createElement('div');
            menubar.id = eleIds.danmakuCtr;
            if (!window.ede.episode_info) {
                menubar.style.opacity = 0.5;
            }
            if (rightButtons) {
                parent.insertBefore(menubar, rightButtons);
            } else {
                parent.append(menubar);
            }
            mediaBtnOpts.forEach(opt => {
                menubar.appendChild(embyButton(opt, opt.onClick));
            });
            console.log('UI初始化完成');
        }, 0);
    }

    async function getEmbyItemInfo() {
        return window.require(['playbackManager']).then((items) => items?.[0].currentItem());
    }

    async function fatchEmbyItemInfo(id) {
        return await ApiClient.getItem(ApiClient.getCurrentUserId(), id);
    }

    async function fetchSearchEpisodes(anime, episode) {
        if (!anime) { throw new Error('anime is required'); }
        const url = dandanplayAPI.searchEpisodes(anime, episode);
        const animaInfo = await fetch(url, {
            method: 'GET',
            headers: {
                'Accept-Encoding': 'gzip',
                Accept: 'application/json',
                'User-Agent': navigator.userAgent,
            },
        })
            .then((response) => response.json())
            .catch((error) => {
                console.log('查询失败:', error);
                return null;
            });
        console.log('查询成功', animaInfo);
        return animaInfo;
    }

    async function fetchComment(episodeId) {
        const url = dandanplayAPI.comment(episodeId, window.ede.chConvert);
        return fetch(url, {
            method: 'GET',
            headers: {
                'Accept-Encoding': 'gzip',
                Accept: 'application/json',
                'User-Agent': navigator.userAgent,
            },
        })
            .then((response) => response.json())
            .then((data) => {
                console.log('弹幕获取成功: ' + data.comments.length);
                return data.comments;
            })
            .catch((error) => {
                console.log('弹幕获取失败:', error);
                return null;
            });
    }

    async function fetchExtcomment(extUrl) {
        const url = dandanplayAPI.extcomment(extUrl);
        return fetch(url, {
            method: 'GET',
            headers: {
                'Accept-Encoding': 'gzip',
                Accept: 'application/json',
                'User-Agent': navigator.userAgent,
            },
        })
            .then((response) => response.json())
            .then((data) => {
                console.log('第三方url弹幕获取成功: ' + data.comments.length);
                return data.comments;
            })
            .catch((error) => {
                console.log('第三方url弹幕获取失败:', error);
                return null;
            });
    }

    async function fetchExtcommentActual(extUrl) {
        let extcomments = await fetchExtcomment(extUrl);
        if (extcomments.length === 0) {
            // 只重试一遍进行弹弹 play 服务器缓存覆盖加载触发
            extcomments = await fetchExtcomment(extUrl);
        }
        return extcomments;
    }

    async function getMapByEmbyItemInfo() {
        const item = await getEmbyItemInfo();
        if (!item) { return null; } // getEmbyItemInfo from playbackManager null, will next called
        let _id;
        let animeName;
        let animeId = -1;
        let episode;
        if (item.Type == 'Episode') {
            _id = item.SeasonId;
            animeName = item.SeriesName;
            episode = item.IndexNumber;
            let session = item.ParentIndexNumber;
            if (session != 1) {
                animeName += ' ' + session;
            }
        } else {
            _id = item.Id;
            animeName = item.Name;
            episode = 'movie';
        }
        let _id_key = '_anime_id_rel_' + _id;
        let _name_key = '_anime_name_rel_' + _id;
        let _episode_key = '_episode_id_rel_' + _id + '_' + episode;
        if (window.localStorage.getItem(_id_key)) {
            animeId = window.localStorage.getItem(_id_key);
        }
        if (window.localStorage.getItem(_name_key)) {
            animeName = window.localStorage.getItem(_name_key);
        }
        return {
            _id: _id,
            _id_key: _id_key,
            _name_key: _name_key,
            _episode_key: _episode_key,
            animeId: animeId,
            episode: episode, // this is episode number, not a index
            animeName: animeName,
            seriesOrMovieId: item.SeriesId || item.Id,
        };
    }

    async function getEpisodeInfo(is_auto = true) {
        const itemInfoMap = await getMapByEmbyItemInfo();
        if (!itemInfoMap) { return null; }
        const { _episode_key, animeId } = itemInfoMap;
        let { animeName, episode } = itemInfoMap;
        if (is_auto && window.localStorage.getItem(_episode_key)) {
            return JSON.parse(window.localStorage.getItem(_episode_key));
        }

        let animeOriginalTitle = null;
        let animaInfo = await fetchSearchEpisodes(animeName, is_auto ? episode : null);
        if (is_auto && animaInfo.animes.length == 0) {
            // from: https://github.com/Izumiko/jellyfin-danmaku/blob/jellyfin/ede.js#L886
            console.log(`标题名: ${animeName},自动匹配未查询到结果,将使用原标题名,重试一次`);
            const seriesOrMovieInfo = await ApiClient.getItem(ApiClient.getCurrentUserId(), itemInfoMap.seriesOrMovieId);
            if (seriesOrMovieInfo.OriginalTitle) {
                animeName = seriesOrMovieInfo.OriginalTitle;
                animaInfo = await fetchSearchEpisodes(animeName, is_auto ? episode : null);
                if (animaInfo.animes.length > 0) { console.log(`使用原标题名: ${animeName},自动匹配成功`); }
                animeOriginalTitle = animeName;
            }
        }
        if (animaInfo.animes.length == 0) {
            console.log(`弹幕查询无结果`);
            return null;
        }

        let selectAnime_id = 1;
        if (animeId != -1) {
            for (let index = 0; index < animaInfo.animes.length; index++) {
                if (animaInfo.animes[index].animeId == animeId) {
                    selectAnime_id = index + 1;
                }
            }
        }
        selectAnime_id = parseInt(selectAnime_id) - 1;
        episode = 0;
        const episodeInfo = {
            episodeId: animaInfo.animes[selectAnime_id].episodes[episode].episodeId,
            episodeTitle: animaInfo.animes[selectAnime_id].type == 'tvseries'
                ? animaInfo.animes[selectAnime_id].episodes[episode].episodeTitle
                : null,
            animeId: animaInfo.animes[selectAnime_id].animeId,
            animeTitle: animaInfo.animes[selectAnime_id].animeTitle,
            animeOriginalTitle,
        };
        localStorage.setItem(_episode_key, JSON.stringify(episodeInfo));
        return episodeInfo;
    }

    async function createDanmaku(comments) {
        if (!comments) { return; }
        if (window.ede.danmaku != null) {
            window.ede.danmaku.destroy();
            window.ede.danmaku = null;
        }
        const commentsParsed = danmakuParser(comments);
        window.ede.commentsParsed = commentsParsed;
        let _comments = danmakuFilter(commentsParsed);
        console.log('弹幕加载成功: ' + _comments.length);

        const _container = document.querySelector(mediaContainerQueryStr);
        const _media = document.querySelector(mediaQueryStr);
        if (!_media) { throw new Error('用户已退出视频播放'); }
        if (!isVersionOld) { _media.style.position = 'absolute'; }
        // from https://github.com/Izumiko/jellyfin-danmaku/blob/jellyfin/ede.js#L1104
        const wrapperTop = 0; // 播放器 UI 顶部阴影
        let wrapper = document.getElementById(eleIds.danmakuWrapper);
        wrapper && wrapper.remove();
        wrapper = document.createElement('div');
        wrapper.id = eleIds.danmakuWrapper;
        wrapper.style.position = 'fixed';
        wrapper.style.width = '100%';
        wrapper.style.height = `calc(${lsGetItem(lsKeys.heightRate.id) * 100}% - ${wrapperTop}px)`;
        // wrapper.style.opacity = lsGetItem(lsKeys.fontOpacity.id); // 弹幕整体透明度
        wrapper.style.top = wrapperTop + 'px';
        wrapper.style.pointerEvents = 'none';
        _container.prepend(wrapper);
        // 弹幕基准速度,这里可以根据屏幕尺寸再计算添加倍率,不过还是设备上手调比较简单
        let _speed = 144 * lsGetItem(lsKeys.speed.id);
        window.ede.danmaku = new Danmaku({
            container: wrapper,
            media: _media,
            comments: _comments,
            engine: lsGetItem(lsKeys.engine.id),
            speed: _speed,
        });
        lsGetItem(lsKeys.switch.id) ? window.ede.danmaku.show() : window.ede.danmaku.hide();
        if (window.ede.ob) {
            window.ede.ob.disconnect();
        }
        window.ede.ob = new ResizeObserver(() => {
            if (window.ede.danmaku) {
                console.log('Resizing');
                window.ede.danmaku.resize();
            }
        });
        window.ede.ob.observe(_container);
        // 自定义的 initH5VideoAdapter 下,解决暂停时暂停的弹幕再次加载会自动恢复问题
        if (!_media.src) {
            require(['playbackManager'], (playbackManager) => {
                if (playbackManager.getPlayerState().PlayState.IsPaused) {
                    _media.dispatchEvent(new Event('pause'));
                }
            });
        }
        // 设置弹窗内的弹幕信息
        buildCurrentDanmakuInfo(currentDanmakuInfoContainerId);
    }

    function loadDanmaku(loadType = LOAD_TYPE.CHECK) {
        if (window.ede.loading) {
            console.log('正在重新加载');
            return;
        }
        window.ede.loading = true;
        getEpisodeInfo(loadType !== LOAD_TYPE.SEARCH)
            .then((info) => {
                return new Promise((resolve, reject) => {
                    if (!info) {
                        if (loadType !== LOAD_TYPE.INIT) {
                            reject('播放器未完成加载');
                        } else {
                            reject(null);
                        }
                    }
                    if (
                        loadType !== LOAD_TYPE.SEARCH &&
                        loadType !== LOAD_TYPE.REFRESH &&
                        loadType !== LOAD_TYPE.RELOAD &&
                        window.ede.danmaku &&
                        window.ede.episode_info &&
                        window.ede.episode_info.episodeId == info.episodeId
                    ) {
                        reject('当前播放视频未变动');
                    } else {
                        window.ede.episode_info = info;
                        resolve(info.episodeId);
                    }
                });
            })
            .then(
                (episodeId) => {
                    if (episodeId) {
                        if (loadType === LOAD_TYPE.RELOAD && window.ede.danmuCache[episodeId]) {
                            createDanmaku(window.ede.danmuCache[episodeId])
                                .then(() => {
                                    console.log('弹幕就位');
                                })
                                .catch((err) => {
                                    console.log(err);
                                });
                        } else {
                            fetchComment(episodeId).then((comments) => {
                                window.ede.danmuCache[episodeId] = comments;
                                createDanmaku(comments)
                                    .then(() => {
                                        console.log('弹幕就位');
                                        // embyToast({ text: `弹幕就位,已获取 ${comments.length} 条弹幕` });
                                    })
                                    .catch((err) => {
                                        console.log(err);
                                    });
                            });
                        }
                    }
                },
                (msg) => {
                    if (msg) {
                        console.log(msg);
                    }
                },
            )
            .then(() => {
                window.ede.loading = false;
                const danmakuCtrEle = document.getElementById(eleIds.danmakuCtr);
                if (danmakuCtrEle && danmakuCtrEle.style.opacity !== '1') {
                    danmakuCtrEle.style.opacity = '1';
                }
            })
            .catch((err) => {
                console.log(err);
            });
    }

    function danmakuFilter(comments) {
        let _comments = [...comments];
        _comments = danmakuTypeFilter(_comments);
        _comments = danmakuSourceFilter(_comments);
        _comments = danmakuDensityLevelFilter(_comments);
        _comments = danmakuKeywordsFilter(_comments);
        return _comments;
    }

    /** 过滤弹幕类型 */
    function danmakuTypeFilter(comments) {
        let idArray = lsGetItem(lsKeys.typeFilter.id);
        // 彩色过滤,只留下默认的白色
        if (idArray.includes(danmakuTypeFilterOpts.onlyWhite.id)) {
            comments = comments.filter(c => '#ffffff' === c.style.color.toLowerCase().slice(0, 7));
            idArray.splice(idArray.indexOf(danmakuTypeFilterOpts.onlyWhite.id), 1);
        }
        // 过滤特定模式的弹幕
        if (idArray.length > 0) {
            comments = comments.filter(c => !idArray.includes(c.mode));
        }
        return comments;
    }

    /** 过滤弹幕来源平台 */
    function danmakuSourceFilter(comments) {
        return comments.filter(c => !(lsGetItem(lsKeys.sourceFilter.id).includes(c.source)));
    }

    /** 过滤弹幕密度等级,水平和垂直 */
    function danmakuDensityLevelFilter(comments) {
        let level = lsGetItem(lsKeys.filterLevel.id);
        if (level == 0) {
            return comments;
        }
        let limit = 9 - level * 2;
        let vertical_limit = 6;
        let arr_comments = [];
        let vertical_comments = [];
        for (let index = 0; index < comments.length; index++) {
            let element = comments[index];
            let i = Math.ceil(element.time);
            let i_v = Math.ceil(element.time / 3);
            if (!arr_comments[i]) {
                arr_comments[i] = [];
            }
            if (!vertical_comments[i_v]) {
                vertical_comments[i_v] = [];
            }
            // TODO: 屏蔽过滤
            if (vertical_comments[i_v].length < vertical_limit) {
                vertical_comments[i_v].push(element);
            } else {
                element.mode = 'rtl';
            }
            if (arr_comments[i].length < limit) {
                arr_comments[i].push(element);
            }
        }
        return arr_comments.flat();
    }

    /** 通过屏蔽关键词过滤弹幕 */
    function danmakuKeywordsFilter(comments) {
        if (!lsGetItem(lsKeys.filterKeywordsEnable.id)) { return comments; }
        const keywords = lsGetItem(lsKeys.filterKeywords.id)
            ?.split(/\r?\n/).map(k => k.trim()).filter(k => k.length > 0 && !k.startsWith('// '));
        if (keywords.length === 0) { return comments; }
        const cKeys = [ 'text', ...Object.keys(showSource) ];
        return comments.filter(comment =>
            !keywords.some(keyword => {
                try {
                    return cKeys.some(key => new RegExp(keyword).test(comment[key]));
                } catch (error) {
                    return cKeys.some(key => comment[key].includes(keyword));
                }
            })
        );
    }

    function danmakuParser($obj) {
        //const fontSize = Number(values[2]) || 25
        // 弹幕大小
        const fontSizeRate = lsGetItem(lsKeys.fontSizeRate.id);
        let fontSize = 25;
        const h3Ele = document.querySelector('.videoOsdTitle');
        if (h3Ele) {
            fontSize = parseFloat(getComputedStyle(h3Ele).fontSize.replace('px', '')) * fontSizeRate;
        } else {
            fontSize = Math.round(
                (window.screen.height > window.screen.width 
                    ? window.screen.width 
                    : window.screen.height / 1080) * 18 * fontSizeRate
            );
        }
        // 弹幕透明度
        const fontOpacity = Math.round(lsGetItem(lsKeys.fontOpacity.id) * 255).toString(16);
        // 时间轴偏移秒数
        const timelineOffset = lsGetItem(lsKeys.timelineOffset.id);
        const sourceUidReg = /\[(.*)\](.*)/;
        const showSourceIds = lsGetItem(lsKeys.showSource.id);
        //const $xml = new DOMParser().parseFromString(string, 'text/xml')
        return $obj
            .map(($comment) => {
                const p = $comment.p;
                //if (p === null || $comment.childNodes[0] === undefined) return null;
                const values = p.split(',');
                const mode = { 6: 'ltr', 1: 'rtl', 5: 'top', 4: 'bottom' }[values[1]];
                if (!mode) return null;
                // 弹幕颜色+透明度
                const color = `000000${Number(values[2]).toString(16)}${fontOpacity}`.slice(-8);
                const cmt = {
                    text: $comment.m,
                    mode,
                    time: values[0] * 1 + timelineOffset,
                    style: {
                        fontSize: `${fontSize}px`,
                        color: `#${color}`,
                        textShadow:
                            color === '00000' ? '-1px -1px #fff, -1px 1px #fff, 1px -1px #fff, 1px 1px #fff' : '-1px -1px #000, -1px 1px #000, 1px -1px #000, 1px 1px #000',

                        font: `${fontSize}px sans-serif`,
                        fillStyle: `#${color}`,
                        strokeStyle: color === '000000' ? `#ffffff${fontOpacity}` : `#000000${fontOpacity}`,
                        lineWidth: 2.0,
                    },
                    [showSource.cid.id]: $comment.cid,
                    [showSource.source.id]: values[3].match(sourceUidReg)?.[1] || danmakuSource.DanDanPlay.id,
                    [showSource.originalUserId.id]: values[3].match(sourceUidReg)?.[2] || values[3],
                };
                if (showSourceIds.length > 0) {
                    cmt.originalText = cmt.text;
                    cmt.text += showSourceIds.map(id => id === showSource.source.id ? `,[${cmt[id]}]` : ',' + cmt[id]).join('');
                }
                cmt.cuid = cmt[showSource.cid.id] + ',' + cmt[showSource.originalUserId.id];
                return cmt;
            })
            .filter((x) => x)
            .sort((a, b) => a.time - b.time);
    }

    function createDialog() {
        const html = `<div id="${eleIds.dialogContainer}"></div>`;
        embyDialog({ html, buttons: [{ name: '关闭' }] });
        waitForElement('#' + eleIds.dialogContainer, afterEmbyDialogCreated);
    }

    async function afterEmbyDialogCreated(dialogContainer) {
        require(["css!modules/emby-elements/emby-textarea/emby-textarea.css"
            , "css!modules/emby-elements/emby-select/emby-select.css"]);
        const itemInfoMap = await getMapByEmbyItemInfo();
        window.ede.searchDanmakuOpts = {
            _id_key: itemInfoMap._id_key,
            _name_key: itemInfoMap._name_key,
            _episode_key: itemInfoMap._episode_key,
            animeId: itemInfoMap.animeId,
            animeName: itemInfoMap.animeName,
            seriesOrMovieId: itemInfoMap.seriesOrMovieId,
            episode: (parseInt(itemInfoMap.episode) || 1) - 1, // convert to index
            animes: [],
        }
        let formDialogHeader = document.querySelector('.formDialogHeader');
        const formDialogFooter = document.querySelector('.formDialogFooter');
        formDialogHeader = formDialogHeader || dialogContainer;
        const tabsMenuContainer = document.createElement('div');
        tabsMenuContainer.className = embyTabsMenuClass;
        tabsMenuContainer.appendChild(embyTabs(danmakuTabOpts, danmakuTabOpts[0].id, 'id', 'name', (value) => {
            danmakuTabOpts.forEach(obj => {
                const elem = document.getElementById(obj.id);
                if (elem) { elem.hidden = obj.id !== value.id; }
            });
        }));
        formDialogHeader.appendChild(tabsMenuContainer);
        formDialogHeader.style = 'width: 100%; padding: 0; height: auto;';

        danmakuTabOpts.forEach((tab, index) => {
            if (tab.hidden) { return; }
            const tabContainer = document.createElement('div');
            tabContainer.id = tab.id;
            tabContainer.style.textAlign = 'left';
            tabContainer.hidden = index != 0;
            dialogContainer.appendChild(tabContainer);
            tab.buildMethod(tab.id);
        });
        if (formDialogFooter) {
            formDialogFooter.style.padding = '0.3em';
        }
    }

    function buildDanmakuSetting(containerId) {
        const container = document.getElementById(containerId);
        let template =  `
            <div style="display: flex; justify-content: center;">
                <div>
                    <div id="${eleIds.danmakuSwitchDiv}" style="margin-bottom: 0.2em;">
                        <label class="${embyLabelClass}">${lsKeys.switch.name} </label>
                    </div>
                    <div id="${eleIds.danmakuChConverDiv}" style="margin-bottom: 0.2em;">
                        <label class="${embyLabelClass}">${lsKeys.chConvert.name}: </label>
                    </div>
                    <div id="${eleIds.danmakuEngineDiv}" style="margin-bottom: 0.2em;">
                        <label class="${embyLabelClass}">${lsKeys.engine.name}: </label>
                    </div>
                    <label class="${embyLabelClass}">弹幕样式: </label>
                    <div style="${embySliderStyle}">
                        <label class="${embyLabelClass}" style="width:4em;">${lsKeys.fontSizeRate.name}: </label>
                        <div id="${eleIds.danmakuSizeDiv}" style="width: 15.5em; text-align: center;"></div>
                        <label id="${eleIds.danmakuSizeLabel}" style="width:4em;"></label>
                    </div>
                    <div style="${embySliderStyle}">
                        <label class="${embyLabelClass}" style="width:4em;">${lsKeys.fontOpacity.name}: </label>
                        <div id="${eleIds.danmakuOpacityDiv}" style="width: 15.5em; text-align: center;"></div>
                        <label id="${eleIds.danmakuOpacityLabel}" style="width:4em;"></label>
                    </div>
                    <div style="${embySliderStyle}">
                        <label class="${embyLabelClass}" style="width:4em;">${lsKeys.speed.name}: </label>
                        <div id="${eleIds.danmakuSpeedDiv}" style="width: 15.5em; text-align: center;"></div>
                        <label id="${eleIds.danmakuSpeedLabel}" style="width:4em;"></label>
                    </div>
                    <div style="${embySliderStyle}">
                        <label class="${embyLabelClass}" style="width:4em;">${lsKeys.timelineOffset.name}: </label>
                        <div id="${eleIds.timelineOffsetDiv}" style="width: 15.5em; text-align: center;"></div>
                        <label id="${eleIds.timelineOffsetLabel}" style="width:4em;"></label>
                    </div>
                    <div id="${eleIds.settingsCtrl}" style="margin: 0.6em 0;"></div>
                    <textarea id="${eleIds.settingsText}" style="display: none;resize: vertical;width: 100%" rows="20" 
                        is="emby-textarea" class="txtOverview emby-textarea"></textarea>
                </div>
            </div>
        `;
        container.innerHTML = template.trim();

        container.querySelector('#' + eleIds.danmakuSwitchDiv).prepend(
            embyButton({ id: eleIds.danmakuSwitch, label: '弹幕开关'
                , iconKey: lsGetItem(lsKeys.switch.id) ? iconKeys.switch_on : iconKeys.switch_off
                , style: (lsGetItem(lsKeys.switch.id) ? 'color:#52b54b;' : '') + 'font-size:1.5em;padding:0;' }
                // , style: lsGetItem(lsKeys.switch.id) ? 'color:#52b54b;font-size:1.5em;padding:0;': 'font-size:1.5em;padding:0;'}
                , doDanmakuSwitch)
        );
        container.querySelector('#' + eleIds.danmakuChConverDiv).appendChild(
            embyTabs(danmakuChConverOpts, window.ede.chConvert, 'id', 'name', doDanmakuChConverChange)
        );
        container.querySelector('#' + eleIds.danmakuEngineDiv).appendChild(
            embyTabs(danmakuEngineOpts, lsGetItem(lsKeys.engine.id), 'id', 'name', doDanmakuEngineSelect)
        );
        // 滑块
        const fontSizeRate = lsGetItem(lsKeys.fontSizeRate.id);
        const fontOpacity = lsGetItem(lsKeys.fontOpacity.id);
        const danmakuSpeed = lsGetItem(lsKeys.speed.id);
        const sizeSlider = embySlider({ labelId: eleIds.danmakuSizeLabel, key: lsKeys.fontSizeRate.id }
            , { value: fontSizeRate }, onDanmakuStyleChange, onDanmakuStyleChangeLabel);
        const alphaSlider = embySlider({ labelId: eleIds.danmakuOpacityLabel, key: lsKeys.fontOpacity.id }
            , { max: 1, value: fontOpacity }, onDanmakuStyleChange, onDanmakuStyleChangeLabel);
        const speedSlider = embySlider({ labelId: eleIds.danmakuSpeedLabel, key: lsKeys.speed.id }
            , { value: danmakuSpeed }, onDanmakuStyleChange, onDanmakuStyleChangeLabel);
        container.querySelector('#' + eleIds.danmakuSizeDiv).appendChild(sizeSlider);
        container.querySelector('#' + eleIds.danmakuOpacityDiv).appendChild(alphaSlider);
        container.querySelector('#' + eleIds.danmakuSpeedDiv).appendChild(speedSlider);
        document.getElementById(eleIds.danmakuSizeLabel).innerText = fontSizeRate;
        document.getElementById(eleIds.danmakuOpacityLabel).innerText = fontOpacity;
        document.getElementById(eleIds.danmakuSpeedLabel).innerText = danmakuSpeed;
        // 弹幕时间轴偏移秒数
        const btnContainer = container.querySelector('#' + eleIds.timelineOffsetDiv);
        const timelineOffsetOpts = { labelId: eleIds.timelineOffsetLabel, key: lsKeys.timelineOffset.id };
        onDanmakuStyleChangeLabel(lsGetItem(lsKeys.timelineOffset.id), timelineOffsetOpts);
        timelineOffsetBtns.forEach(btn => {
            btnContainer.appendChild(embyButton(btn, (e) => {
                if (e.target) {
                    let oldValue = lsGetItem(lsKeys.timelineOffset.id);
                    let newValue = oldValue + (parseFloat(e.target.getAttribute('valueOffset')) || 0);
                    // 如果 offset 为 0,则 newValue 应该设置为 0
                    if (newValue === oldValue) { newValue = 0; }
                    onDanmakuStyleChange(newValue, timelineOffsetOpts);
                }
            }));
        });
        // 配置 JSON 导入,导出
        buildSettingsBackup(container);
    }

    function buildSettingsBackup(container) {
        const settingsCtrlEle = container.querySelector('#' + eleIds.settingsCtrl);
        settingsCtrlEle.appendChild(
            embyButton({ label: '配置', iconKey: iconKeys.more }, (e) => {
                const checked = !e.target.checked;
                e.target.checked = checked;
                e.target.title = checked ? '关闭' : '配置';
                e.target.firstChild.innerHTML = checked ? iconKeys.close : iconKeys.more;
                const settingsTextEle = document.getElementById(eleIds.settingsText);
                settingsTextEle.style.display = checked ? '' : 'none';
                if (checked) { settingsTextEle.value = getSettingsJson(2); }
                [eleIds.settingReloadBtn, eleIds.settingsImportBtn].forEach(id => {
                    document.getElementById(id).style.display = checked ? '' : 'none';
                });
            })
        );
        settingsCtrlEle.appendChild(
            embyButton({ id: eleIds.settingReloadBtn, label: '刷新', iconKey: iconKeys.refresh, style: 'display: none;' }
                , () => document.getElementById(eleIds.settingsText).value = getSettingsJson(2))
        );
        settingsCtrlEle.appendChild(
            embyButton({ id: eleIds.settingsImportBtn, label: '应用', iconKey: iconKeys.done, style: 'display: none;' }, () => {
                // const settings = JSON.parse(document.getElementById(eleIds.settingsText).value);
                // lsMultiSet(Object.fromEntries(Object.entries(settings).map(([key, valueObj]) => [key, valueObj.value])));
                lsMultiSet(JSON.parse(document.getElementById(eleIds.settingsText).value));
                loadDanmaku(LOAD_TYPE.INIT);
                closeEmbyDialog();
            })
        );
    }

    function buildSearchEpisode(containerId) {
        const container = document.getElementById(containerId);
        const comments = window.ede.danmuCache[window.ede.episode_info?.episodeId] ?? [];
        let template = `
            <div>
                <div>
                    <label class="${embyLabelClass}">标题: </label>
                    <div style="display: flex;" id="${eleIds.danmakuSearchNameDiv}"></div>
                </div>
                <div id="${eleIds.danmakuEpisodeFlag}" hidden>
                    <div style="display: flex;">
                        <div style="width: 80%;">
                            <label class="${embyLabelClass}">媒体名: </label>
                            <div id="${eleIds.danmakuAnimeDiv}" class="${embySelectWrapperClass}"></div>
                            <label class="${embyLabelClass}">分集名: </label>
                            <div style="display: flex;">
                                <div id="${eleIds.danmakuEpisodeNumDiv}" style="max-width: 90%;" class="${embySelectWrapperClass}"></div>
                                <div id="${eleIds.danmakuEpisodeLoad}"></div>
                            </div>
                        </div>
                        <img id="${eleIds.searchImg}" style="width: 20%;height: 100%;margin: 2%;"
                            loading="lazy" decoding="async" draggable="false" class="coveredImage-noScale"></img>
                        </div>
                    </div>
                <div>
                    <label class="${embyLabelClass}" id="${eleIds.danmakuRemark}"></label>
                </div>
                <div>
                    <h3>匹配源</h3>
                    <div>
                        <div id="${eleIds.currentMatchedDiv}">
                            <label class="${embyLabelClass}">弹弹play总量: ${comments.length}</label>
                        </div>
                        <label class="${embyLabelClass}">弹弹play附加第三方url: </label>
                    </div>
                    <div id="${eleIds.extUrlsDiv}"></div>
                </div>
                <div>
                    <h3>附加弹幕</h3>
                    <label class="${embyLabelClass}">弹弹play支持解析的第三方url(如A/B/C站): </label>
                    <div style="display: flex;" id="${eleIds.extConmentSearchDiv}"></div>
                </div>
            </div>
        `;
        container.innerHTML = template.trim();
        const searchNameDiv = container.querySelector('#' + eleIds.danmakuSearchNameDiv);
        searchNameDiv.appendChild(embyInput({ id: eleIds.danmakuSearchName, value: window.ede.searchDanmakuOpts.animeName, type: 'search' }
            , doDanmakuSearchEpisode));
        searchNameDiv.appendChild(embyButton({ id: eleIds.danmakuSearchEpisode, label: '搜索', iconKey: iconKeys.search}
            , doDanmakuSearchEpisode));
        searchNameDiv.appendChild(embyButton({ label: '切换[原]标题', iconKey: iconKeys.text_format }, doSearchTitleSwtich));
        container.querySelector('#' + eleIds.danmakuEpisodeLoad).appendChild(
            embyButton({ id: eleIds.danmakuSwitchEpisode, label: '加载弹幕', iconKey: iconKeys.done }, doDanmakuSwitchEpisode)
        );
        buildExtConment(container, comments);
    }

    function buildExtConment(container, comments) {
        const extConmentSearchDiv = container.querySelector('#' + eleIds.extConmentSearchDiv);
        extConmentSearchDiv.appendChild(embyInput({ type: 'search', placeholder: 'http(s)://' }, async (e) => {
            const extUrl = e.target.value.trim();
            if (!extUrl.startsWith('http')) { return embyToast({ text: '输入的url错误!' }); }
            let extcomments = window.ede.extConmentCache[extUrl];
            if (!extcomments) {
                extcomments = await fetchExtcommentActual(extUrl);
                extcomments.map(c => c.fromUrl = extUrl);
                window.ede.extConmentCache[extUrl] = extcomments;
            }
            let allComments = comments.concat(...Object.values(window.ede.extConmentCache));
            createDanmaku(allComments)
            .then(() => {
                const beforeLength = window.ede.commentsParsed.length - extcomments.length;
                embyToast({ text: `此次附加总量: ${extcomments.length}, 附加前总量: ${beforeLength}, 附加后总量: ${allComments.length}` });
                console.log(`附加弹幕就位, 附加前总量: ${beforeLength}`);
                const currentMatchedDiv = container.querySelector('#' + eleIds.currentMatchedDiv);
                if (comments && !currentMatchedDiv.querySelector('button')) {
                    currentMatchedDiv.prepend(
                        embyButton({ label: '清空此加载', iconKey: iconKeys.close }, (e) => {
                            const xChecked = !e.target.xChecked;
                            allComments = (xChecked ? [] : comments).concat(...Object.values(window.ede.extConmentCache));
                            if (allComments.length > 0) {
                                e.target.xChecked = xChecked;
                                e.target.title = xChecked ? '还原' : '清空此加载';
                                e.target.firstChild.innerHTML = xChecked ? iconKeys.refresh : iconKeys.close;
                                createDanmaku(allComments);
                            }
                            // 不允许清空所有弹幕,历史遗留将导致多处报错
                        })
                    );
                }
                const extUrlsDiv = container.querySelector('#' + eleIds.extUrlsDiv);
                extUrlsDiv.innerHTML = '';
                Object.entries(window.ede.extConmentCache).map(([key, val]) => {
                    const extUrlDiv = document.createElement('div');
                    extUrlDiv.append(embyButton({ label: '清空此加载', iconKey: iconKeys.close }, (e) => {
                        delete window.ede.extConmentCache[key];
                        e.target.parentNode.remove();
                        if (Object.keys(window.ede.extConmentCache).length === 0) {
                            const currentMatchedClearBtn = currentMatchedDiv.querySelector('button');
                            if (currentMatchedClearBtn) {
                                currentMatchedClearBtn.remove();
                                if (currentMatchedClearBtn.firstChild.textContent === iconKeys.refresh) {
                                    currentMatchedClearBtn.click();
                                }
                            }
                        }
                        createDanmaku(allComments.filter(c => c.fromUrl !== key));
                    }));
                    extUrlDiv.append(embyALink(key, key), document.createTextNode(` 总量: ${val.length}`));
                    extUrlsDiv.append(extUrlDiv);
                });
            })
            .catch(err => console.log(err));
        }));
    }

    function buildCurrentDanmakuInfo(containerId) {
        const container = document.getElementById(containerId);
        if (!container || !window.ede.episode_info) { return; }
        const { episodeTitle, animeId, animeTitle } = window.ede.episode_info;
        const loadSum = window.ede.danmaku?.comments.length ?? 0;
        const downloadSum = window.ede.commentsParsed.length;
        let template = `
            <div style="display: flex;">
                ${!animeId || animeId === -1 ? '' :
                `<img src="${dandanplayAPI.posterImg(animeId)}" style="width: 20%;height: 100%;margin-right: 2%;" 
                    loading="lazy" decoding="async" draggable="false" class="coveredImage-noScale"></img>`}
                <div>
                    <div>
                        <label class="${embyLabelClass}">媒体名: </label>
                        <div class="${embyFieldDescClass}">${animeTitle}</div>
                    </div>
                    ${!episodeTitle ? '' :
                    `<div>
                        <label class="${embyLabelClass}">分集名: </label>
                        <div class="${embyFieldDescClass}">${episodeTitle}</div>
                    </div>`}
                    <div>
                        <label class="${embyLabelClass}">其它信息: </label>
                        <div class="${embyFieldDescClass}">
                            获取总数: ${downloadSum}, 
                            加载总数: ${loadSum}, 
                            被过滤数: ${downloadSum - loadSum}
                        </div>
                    </div>
                </div>
            </div>
            <div style="margin-top: 2%;">
                <label class="${embyLabelClass}">${lsKeys.danmuList.name}: </label>
                <div id="${eleIds.danmuListDiv}" style="margin: 1% 0;"></div>
                <textarea id="${eleIds.danmuListText}" readOnly style="display: none;resize: vertical;width: 100%" rows="8" 
                    is="emby-textarea" class="txtOverview emby-textarea"></textarea>
            </div>
        `;
        container.innerHTML = template.trim();

        container.querySelector('#' + eleIds.danmuListDiv).appendChild(
            embyTabs(
                danmuListOpts.concat(
                    Object.values(window.ede.extConmentCache).map((value, index) => {
                        return { id: `ext${index + 1}`, name: `附加${index + 1}`, onChange: () => danmakuParser(value) };
                    })
                ), lsKeys.danmuList.defaultValue, 'id', 'name', doDanmuListOptsChange
            )
        );
    }

    function buildDanmakuFilter(containerId) {
        const container = document.getElementById(containerId);
        let template = `
            <div>
                <div id="${eleIds.danmakuTypeFilterDiv}" style="margin-bottom: 0.2em;">
                    <label class="${embyLabelClass}">${lsKeys.typeFilter.name}: </label>
                </div>
                <div id="${eleIds.danmakuSourceFilterDiv}">
                    <label class="${embyLabelClass}">${lsKeys.sourceFilter.name}: </label>
                </div>
                <div id="${eleIds.danmakuShowSourceDiv}">
                    <label class="${embyLabelClass}">${lsKeys.showSource.name}: </label>
                </div>
                <div id="${eleIds.danmakuFilterLevelDiv}">
                    <label class="${embyLabelClass}">${lsKeys.filterLevel.name}: </label>
                </div>
                <div id="${eleIds.danmakuHeightRateDiv}">
                    <label class="${embyLabelClass}">${lsKeys.heightRate.name}: </label>
                </div>
                <div id="${eleIds.filterKeywordsDiv}" style="margin-bottom: 0.2em;">
                    <label class="${embyLabelClass}">${lsKeys.filterKeywords.name}: </label>
                </div>
            </div>
        `;
        container.innerHTML = template.trim();

        container.querySelector('#' + eleIds.danmakuTypeFilterDiv).appendChild(
            embyCheckboxList(null, eleIds.danmakuTypeFilterSelectName
                , lsGetItem(lsKeys.typeFilter.id), Object.values(danmakuTypeFilterOpts), doDanmakuTypeFilterSelect)
        );
        container.querySelector('#' + eleIds.danmakuSourceFilterDiv).appendChild(
            embyCheckboxList(null, eleIds.danmakuSourceFilterSelectName
                , lsGetItem(lsKeys.sourceFilter.id), Object.values(danmakuSource), doDanmakuSourceFilterSelect)
        );
        container.querySelector('#' + eleIds.danmakuShowSourceDiv).appendChild(
            embyCheckboxList(null, eleIds.danmakuShowSourceSelectName
                , lsGetItem(lsKeys.showSource.id), Object.values(showSource), doDanmakuShowSourceSelect)
        );
        container.querySelector('#' + eleIds.danmakuFilterLevelDiv).appendChild(
            embyTabs(danmakuFilterLevelOpts, lsGetItem(lsKeys.filterLevel.id), 'id', 'name', (value) => {
                if (lsCheckSet(lsKeys.filterLevel.id, parseInt(value.id))) { loadDanmaku(LOAD_TYPE.RELOAD); }
            })
        );
        container.querySelector('#' + eleIds.danmakuHeightRateDiv).appendChild(
            embyTabs(danmakuHeightRateOpts, lsGetItem(lsKeys.heightRate.id) , 'id', 'name', (value) => {
                if (lsCheckSet(lsKeys.heightRate.id, value.id)) { loadDanmaku(LOAD_TYPE.RELOAD); }
            })
        );
        // 屏蔽关键词
        const keywordsContainer = container.querySelector('#' + eleIds.filterKeywordsDiv);
        const keywordsEnableDiv = keywordsContainer.appendChild(document.createElement('div'));
        const keywordsBtn = embyButton({ label: '加载关键词过滤', iconKey: iconKeys.done_disabled }, doDanmakuFilterKeywordsBtnClick);
        keywordsBtn.disabled = true;
        keywordsEnableDiv.setAttribute('style', 'display: flex; justify-content: space-between; align-items: center; width: 100%;');
        keywordsEnableDiv.appendChild(
            embyCheckbox({ id: eleIds.filterKeywordsEnableId, label: lsKeys.filterKeywordsEnable.name.replace(lsKeys.filterKeywords.name, '') }
                , lsGetItem(lsKeys.filterKeywordsEnable.id), (flag) => updateFilterKeywordsBtn(keywordsBtn, flag
                    , document.getElementById(eleIds.filterKeywordsId).value.trim()))
        );
        keywordsEnableDiv.appendChild(document.createElement('div')).appendChild(keywordsBtn);
        keywordsContainer.appendChild(document.createElement('div')).appendChild(
            embyTextarea({id: eleIds.filterKeywordsId, value: lsGetItem(lsKeys.filterKeywords.id)
                , style: 'width: 100%;margin-top: 0.2em;', rows: 8}, (event) => updateFilterKeywordsBtn(keywordsBtn
                , document.getElementById(eleIds.filterKeywordsEnableId).checked, event.target.value.trim()))
        );
        const label = document.createElement('label');
        label.innerText = `关键词/正则匹配过滤,支持过滤[正文,${Object.values(showSource).map(o => o.name).join()}],多个表达式用换行分隔`;
        label.className = embyFieldDescClass;
        keywordsContainer.appendChild(document.createElement('div')).appendChild(label);
    }

    function buildAbout(containerId) {
        const container = document.getElementById(containerId);
        if (!container) { return; }
        let licenseHtml = '';
        Object.entries(openSouceLicense).map(([key, val]) => {
            licenseHtml += embyALink([key, val.name, val.version, val.license].join(':'), val.url).outerHTML;
        });
        const template = `
            <div id="${eleIds.consoleLogCtrl}"></div>
            <textarea id="${eleIds.consoleLogText}" readOnly style="resize: vertical;width: 100%;margin-top: 0.6em;" 
                rows="14" is="emby-textarea" class="txtOverview emby-textarea"></textarea>
            <div class="${embyFieldDescClass}">注意开启后原本控制台中调用方信息将被覆盖,不使用请保持关闭状态</div>
            <div>
                <h3>开发者选项</h3>
                <label class="${embyLabelClass}">调试开关(不持久化,自行开关): </label>
                <div id="${eleIds.debugCheckbox}"></div>
                <label class="${embyLabelClass}">调试按钮: </label>
                <div id="${eleIds.debugButton}"></div>
            </div>
            <div>
                <h3>开放源代码许可</h3>
                <div style="display: flex; flex-direction: column;">
                    ${licenseHtml}
                </div>
            </div>
        `;
        container.innerHTML = template.trim();
        buildConsoleLog(container);
        buildDebugCheckbox(container);
        buildDebugButton(container);
    }

    function buildConsoleLog(container) {
        const consoleLogEnable = lsGetItem(lsKeys.consoleLogEnable.id);
        const consoleLogTextEle = container.querySelector('#' + eleIds.consoleLogText);
        consoleLogTextEle.style.display = consoleLogEnable ? '' : 'none';
        if (consoleLogEnable) { doConsoleLogChange(consoleLogEnable); }
        const consoleLogCtrlEle = container.querySelector('#' + eleIds.consoleLogCtrl);
        consoleLogCtrlEle.appendChild(embyCheckbox({ label: lsKeys.consoleLogEnable.name }, consoleLogEnable, doConsoleLogChange));
        consoleLogCtrlEle.appendChild(
            embyButton({ label: '清除', iconKey: iconKeys.block }
                , () => { consoleLogTextEle.value = ''; window.ede.appLogAspect.value = ''; })
        );
    }

    function buildDebugCheckbox(container) {
        const debugWrapper = container.querySelector('#' + eleIds.debugCheckbox);
        // 尽量接近浏览器控制台选定元素背景色
        const highlightColor  = 'rgba(115, 160, 255, 0.3)';
        debugWrapper.appendChild(embyCheckbox({ label: '弹幕容器边界' }, false, (checked) => {
            const wrapper = document.getElementById(eleIds.danmakuWrapper);
            wrapper.style.backgroundColor = checked ? highlightColor : '';
            if (!checked) { return; }
            console.log(`弹幕容器(#${eleIds.danmakuWrapper})宽高像素:`, wrapper.offsetWidth, wrapper.offsetHeight);
            const stage = wrapper.firstChild;
            console.log(`实际舞台(${stage.tagName})宽高像素:`, stage.offsetWidth, stage.offsetHeight);
        }));
        debugWrapper.appendChild(embyCheckbox({ label: '按钮容器边界' }, false, (checked) => {
            const wrapper = document.getElementById(eleIds.danmakuCtr);
            wrapper.style.backgroundColor = checked ? highlightColor : '';
            if (!checked) { return; }
            console.log(`按钮容器(#${eleIds.danmakuCtr})宽高像素:`, wrapper.offsetWidth, wrapper.offsetHeight);
        }));
    }

    function buildDebugButton(container) {
        const debugWrapper = container.querySelector('#' + eleIds.debugButton);
        debugWrapper.appendChild(embyButton({ label: '打印视频加载方' }, () => {
            const _media = document.querySelector(mediaQueryStr);
            if (_media.src) {
                console.log('视频加载方为 Web 端 <video> 标签:', _media.parentNode.outerHTML);
            } else {
                console.log('当前 <video> 标签为虚拟适配器:', _media.outerHTML);
                const _embed = document.querySelector('embed');
                if (_embed) {
                    console.log('视频加载方为 <embed> 标签占位的 Native 播放器:', _embed.parentNode.outerHTML);
                } else {
                    console.log('视频加载方为无占位标签的 Native 播放器,无信息');
                }
            }
        }));
    }
  
    function doDanmakuSwitch() {
        console.log('切换弹幕开关');
        const flag = !lsGetItem(lsKeys.switch.id);
        flag ? window.ede.danmaku.show() : window.ede.danmaku.hide();
        document.getElementById(eleIds.danmakuSwitchBtn).firstChild.innerHTML = flag ? iconKeys.comment : iconKeys.comments_disabled;
        const switchElement = document.getElementById(eleIds.danmakuSwitch);
        if (switchElement) {
            switchElement.firstChild.innerHTML = flag ? iconKeys.switch_on : iconKeys.switch_off;
            switchElement.style.color = flag ? '#52b54b' : '';
        }
        lsSetItem(lsKeys.switch.id, flag);
    }

    async function doDanmakuSearchEpisode() {
        let embySearch = document.getElementById(eleIds.danmakuSearchName);
        if (!embySearch) { return; }
        let searchName = embySearch.value
        const danmakuRemarkEle = document.getElementById(eleIds.danmakuRemark);
        danmakuRemarkEle.innerText = searchName ? '' : '请填写标题';
        const spinnerEle = document.querySelector(".mdl-spinner");
        spinnerEle.classList.remove('hide');
      
        const animaInfo = await fetchSearchEpisodes(searchName);
        spinnerEle.classList.add('hide');
        if (!animaInfo || animaInfo.animes.length < 1) {
            danmakuRemarkEle.innerText = '搜索结果为空';
            document.getElementById(eleIds.danmakuSwitchEpisode).disabled = true;
            document.getElementById(eleIds.danmakuEpisodeFlag).hidden = true;
            return;
        } else {
            danmakuRemarkEle.innerText = '';
        }
        const danmakuAnimeDiv = document.getElementById(eleIds.danmakuAnimeDiv);
        const danmakuEpisodeNumDiv = document.getElementById(eleIds.danmakuEpisodeNumDiv);
        danmakuAnimeDiv.innerHTML = '';
        danmakuEpisodeNumDiv.innerHTML = '';
        const animes = animaInfo.animes;
        window.ede.searchDanmakuOpts.animes = animes;

        let selectAnimeIdx = animes.findIndex(anime => anime.animeId == window.ede.searchDanmakuOpts.animeId);
        selectAnimeIdx = selectAnimeIdx !== -1 ? selectAnimeIdx : 0;
        const animeSelect = embySelect({ id: eleIds.danmakuAnimeSelect, label: '剧集: ', style: 'width: auto;max-width: 100%;' }
            , selectAnimeIdx, animes, 'animeId', opt => `${opt.animeTitle} 类型:${opt.typeDescription}`, doDanmakuAnimeSelect);
        danmakuAnimeDiv.appendChild(animeSelect);
        const episodeNumSelect = embySelect({ id: eleIds.danmakuEpisodeNumSelect, label: '集数: ', style: 'width: auto;max-width: 100%;' }
            , window.ede.searchDanmakuOpts.episode, animes[selectAnimeIdx].episodes, 'episodeId', 'episodeTitle');
        danmakuEpisodeNumDiv.appendChild(episodeNumSelect);
        document.getElementById(eleIds.danmakuEpisodeFlag).hidden = false;
        document.getElementById(eleIds.danmakuSwitchEpisode).disabled = false;
        document.getElementById(eleIds.searchImg).src = dandanplayAPI.posterImg(animes[selectAnimeIdx].animeId);
    }

    function doSearchTitleSwtich(e) {
        const searchInputEle = document.getElementById(eleIds.danmakuSearchName);
        const attrKey = 'isOriginalTitle';
        if ('1' === e.target.getAttribute(attrKey)) {
            e.target.setAttribute(attrKey, '0');
            return searchInputEle.value = window.ede.searchDanmakuOpts.animeName;
        }
        const { _episode_key, seriesOrMovieId } = window.ede.searchDanmakuOpts;
        const episode_info = JSON.parse(localStorage.getItem(_episode_key));
        const { animeOriginalTitle } = episode_info; 
        if (animeOriginalTitle) {
            e.target.setAttribute(attrKey, '1');
            return searchInputEle.value = animeOriginalTitle;
        }
        ApiClient.getItem(ApiClient.getCurrentUserId(), seriesOrMovieId).then(item => {
            if (item.OriginalTitle) {
                e.target.setAttribute(attrKey, '1');
                searchInputEle.value = item.OriginalTitle;
                episode_info.animeOriginalTitle = item.OriginalTitle;
                localStorage.setItem(_episode_key, JSON.stringify(episode_info));
                window.ede.episode_info.animeOriginalTitle = item.OriginalTitle;
            }
        });
    }

    function doDanmakuAnimeSelect(value, index, option) {
        const numDiv = document.getElementById(eleIds.danmakuEpisodeNumDiv);
        numDiv.innerHTML = '';
        const anime = window.ede.searchDanmakuOpts.animes[index];
        const episodeNumSelect = embySelect({ id: eleIds.danmakuEpisodeNumSelect, label: '集数: ' }, index
            , anime.episodes, 'episodeId', 'episodeTitle');
        episodeNumSelect.style.maxWidth = '100%';
        numDiv.appendChild(episodeNumSelect);
        document.getElementById(eleIds.searchImg).src = dandanplayAPI.posterImg(anime.animeId);
    }

    function doDanmakuSwitchEpisode() {
        const animeSelect = document.getElementById(eleIds.danmakuAnimeSelect);
        const episodeNumSelect = document.getElementById(eleIds.danmakuEpisodeNumSelect);
        const anime = window.ede.searchDanmakuOpts.animes[animeSelect.selectedIndex];

        const episodeInfo = {
            episodeId: episodeNumSelect.value,
            episodeTitle: anime.type == 'tvseries' 
                ?  episodeNumSelect.options[episodeNumSelect.selectedIndex].text
                : null,
            animeId: anime.animeId,
            animeTitle: anime.animeTitle,
        }
        localStorage.setItem(window.ede.searchDanmakuOpts._episode_key, JSON.stringify(episodeInfo));
        console.log(`手动匹配信息:`, episodeInfo);
        loadDanmaku(LOAD_TYPE.RELOAD);
        closeEmbyDialog();
    }

    function doDanmakuEngineSelect(value) {
        let selectedValue = value.id;
        if (lsCheckSet(lsKeys.engine.id, selectedValue)) { 
            console.log(`已更改弹幕引擎为: ${selectedValue}`);
            loadDanmaku(LOAD_TYPE.RELOAD);
        }
    }

    function doDanmakuChConverChange(value) {
        window.ede.chConvert = value.id;
        lsSetItem(lsKeys.chConvert.id, window.ede.chConvert);
        loadDanmaku(LOAD_TYPE.REFRESH);
        console.log(value.name);
    }

    function doDanmuListOptsChange(value, index) {
        const danmuListEle = document.getElementById(eleIds.danmuListText);
        danmuListEle.style.display = index == lsKeys.danmuList.defaultValue ? 'none' : '';
        const f = new Intl.DateTimeFormat('default', { minute: '2-digit', second: '2-digit' });
        const hasShowSourceIds = lsGetItem(lsKeys.showSource.id).length > 0;
        danmuListEle.value = value.onChange(window.ede)
            .map((c, i) => `[${i + 1}] [${f.format(new Date(c.time * 1000))}] : `
                + `${hasShowSourceIds ? c.originalText : c.text} [${c.source}],[${c.originalUserId}],[${c.cid}]`
            ).join('\n');
    }

    function doDanmakuTypeFilterSelect() {
        const checkList = Array.from(document.getElementsByName(eleIds.danmakuTypeFilterSelectName))
            .filter(item => item.checked).map(item => item.value);
        lsSetItem(lsKeys.typeFilter.id, checkList);
        loadDanmaku(LOAD_TYPE.RELOAD);
        const idNameMap = new Map(Object.values(danmakuTypeFilterOpts).map(opt => [opt.id, opt.name]));
        console.log(`当前弹幕类型过滤为: ${JSON.stringify(checkList.map(s => idNameMap.get(s)))}`);
    }

    function doDanmakuSourceFilterSelect() {
        const checkList = Array.from(document.getElementsByName(eleIds.danmakuSourceFilterSelectName))
            .filter(item => item.checked).map(item => item.value);
        lsSetItem(lsKeys.sourceFilter.id, checkList);
        loadDanmaku(LOAD_TYPE.RELOAD);
        console.log(`当前弹幕来源平台过滤为: ${JSON.stringify(checkList)}`);
    }

    function doDanmakuShowSourceSelect() {
        const checkList = Array.from(document.getElementsByName(eleIds.danmakuShowSourceSelectName))
            .filter(item => item.checked).map(item => item.value);
        lsSetItem(lsKeys.showSource.id, checkList);
        loadDanmaku(LOAD_TYPE.RELOAD);
        const idNameMap = new Map(Object.values(showSource).map(opt => [opt.id, opt.name]));
        console.log(`当前弹幕显示来源为: ${JSON.stringify(checkList.map(s => idNameMap.get(s)))}`);
    }

    function onDanmakuStyleChange(val, props) {
        onDanmakuStyleChangeLabel(val, props);
        if (props?.key && lsCheckSet(props.key, val)) {
            console.log(`${props.key} changed to ${val}`);
            loadDanmaku(LOAD_TYPE.RELOAD);
        }
    }

    function onDanmakuStyleChangeLabel(val, props) {
        if (props?.labelId) { document.getElementById(props.labelId).innerText = val; }
    }
  
    function doDanmakuFilterKeywordsBtnClick(event) {
        const btn = event.currentTarget;
        if (btn) {
            btn.style = '';
            btn.disabled = true;
        }
        let keywords = document.getElementById(eleIds.filterKeywordsId).value.trim();
        let enable = document.getElementById(eleIds.filterKeywordsEnableId).checked;
        lsCheckSet(lsKeys.filterKeywordsEnable.id, enable);

        if (!lsCheckSet(lsKeys.filterKeywords.id, keywords) && keywords === '') { return; }
        loadDanmaku(LOAD_TYPE.RELOAD);
    }

    function updateFilterKeywordsBtn(btn, flag, keywords) {
        const isSame = lsCheckOld(lsKeys.filterKeywordsEnable.id, flag) && lsCheckOld(lsKeys.filterKeywords.id, keywords);
        btn.firstChild.innerHTML = isSame ? iconKeys.done_disabled : iconKeys.done;
        btn.disabled = isSame;
    }

    function doConsoleLogChange(checked) {
        lsSetItem(lsKeys.consoleLogEnable.id, checked);
        const consoleLogTextEle = document.getElementById(eleIds.consoleLogText);
        consoleLogTextEle.style.display = checked ? '' : 'none';
        if (checked) {
            if (!window.ede.appLogAspect) {
                window.ede.appLogAspect = new AppLogAspect().init();
            }
            consoleLogTextEle.value = window.ede.appLogAspect.value;
            window.ede.appLogAspect.on(newValue => {
                if (consoleLogTextEle.value.length !== newValue.length) {
                    consoleLogTextEle.value = newValue;
                    consoleLogTextEle.scrollTop = consoleLogTextEle.scrollHeight;
                }
            });
        } else {
            consoleLogTextEle.value = '';
            window.ede.appLogAspect.destroy();
            window.ede.appLogAspect = null;
        }
    }

    /** props: {id: 'inputId', value: '', type: '', style: '',...} for setAttribute(key, value)
     * function will not setAttribute
     */
    function embyInput(props, onEnter, onChange) {
        const input = document.createElement('input', { is: 'emby-input' });
        Object.entries(props).forEach(([key, value]) => {
            if (typeof value !== 'function') { input.setAttribute(key, value); }
        });
        input.className = embyInputClass; // searchfields-txtSearch: 半圆角
        if (typeof onEnter === 'function') {
            input.addEventListener('keyup', (e) => { if (e.key === 'Enter') { onEnter(e); } });
        }
        if (typeof onChange === 'function') { input.addEventListener('change', onChange); }
        // 控制器输入左右超出边界时切换元素
        input.addEventListener('keydown', (event) => {
            if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight') && 
                    ((input.selectionStart === 0 && event.key === 'ArrowLeft') ||
                        (input.selectionEnd === input.value.length && event.key === 'ArrowRight'))) {
                event.stopPropagation();
                event.preventDefault()
                var options = {sourceElement: event.target, repeat: event.repeat, originalEvent: event };
                require(['inputmanager'], (inputmanager) => {
                    inputmanager.trigger(event.key.replace('Arrow', '').toLowerCase(), options);
                });
            }
        });
        return input;
    }

    /** props: {id: 'btnId', label: 'label text', style: '', iconKey: '',...} for setAttribute(key, value)
     * 'iconKey' will innerHTML <i>iconKey</i>|function will not setAttribute
     */
    function embyButton(props, onClick) {
        const button = document.createElement('button', { is: 'emby-button' });
        button.setAttribute('type', 'button');
        Object.entries(props).forEach(([key, value]) => {
            if (key !== 'iconKey' &&  typeof value !== 'function') { button.setAttribute(key, value); }
        });
        if (props.iconKey) {
            button.setAttribute('title', props.label);
            button.setAttribute('aria-label', props.label);
            button.innerHTML = `<i class="md-icon autortl" style="pointer-events: none;">${props.iconKey}</i>`;
            button.className = embyIconButtonClass;
        } else {
            button.className = 'btnOption raised emby-button';
            button.textContent = props.label;
        }
        if (typeof onClick === 'function') { button.addEventListener('click', onClick); }
        return button;
    }

    function embyTabs(options, selectedValue, optionValueKey, optionTitleKey, onChange) {
        const tabs = document.createElement('div', { is: 'emby-tabs' });
        tabs.setAttribute('data-index', '0');
        tabs.className = embyTabsDivClass1;
        tabs.style.width = 'fit-content';
        const tabsSlider = document.createElement('div');
        tabsSlider.className = embyTabsDivClass2;
        tabsSlider.style.padding = '0.25em';
        options.forEach((option, index) => {
            if (option.hidden) { return; }
            const value = getValueOrInvoke(option, optionValueKey);
            const title = getValueOrInvoke(option, optionTitleKey);
            const tabButton = document.createElement('button');
            tabButton.className = `${embyTabsButtonClass}${value == selectedValue ? ' emby-tab-button-active' : ''}`;
            tabButton.setAttribute('data-index', index);
            tabButton.textContent = title;
            tabsSlider.appendChild(tabButton);
        });
        tabs.appendChild(tabsSlider);
        if (typeof onChange === 'function') {
            tabs.addEventListener('tabchange', e => onChange(options[e.detail.selectedTabIndex], e.detail.selectedTabIndex));
        }
        return tabs;
    }

    function embySelect(props, selectedIndexOrValue, options, optionValueKey, optionTitleKey, onChange) {
        const defaultProps = { class: 'emby-select' };
        props = { ...defaultProps, ...props };
        if (!Number.isInteger(selectedIndexOrValue)) {
            selectedIndexOrValue = options.indexOf(selectedIndexOrValue);
        }
        const selectElement = document.createElement('select', { is: 'emby-select'});
        Object.entries(props).forEach(([key, value]) => {
            if (typeof value !== 'function') { selectElement.setAttribute(key, value); }
        });
        options.forEach((option, index) => {
            const value = getValueOrInvoke(option, optionValueKey);
            const title = getValueOrInvoke(option, optionTitleKey);
            const optionElement = document.createElement('option');
            optionElement.value = value;
            optionElement.textContent = title;
            if (index === selectedIndexOrValue) {
                optionElement.selected = true;
            }
            selectElement.appendChild(optionElement);
        });
        if (typeof onChange === 'function') {
            selectElement.addEventListener('change', e => {
                onChange(e.target.value, e.target.selectedIndex, options[e.target.selectedIndex]);
            });
        }
        return selectElement;
    }
  
    function embyCheckboxList(id, checkBoxName, selectedStrArray, options, onChange, isVertical = false) {
        const checkboxContainer = document.createElement('div');
        checkboxContainer.setAttribute('class', embyCheckboxListClass);
        checkboxContainer.setAttribute('style', isVertical ? '' : embyCheckboxListStyle);
        checkboxContainer.setAttribute('id', id);
        options.forEach(option => {
            checkboxContainer.appendChild(embyCheckbox({ name: checkBoxName, label: option.name, value: option.id }
                , selectedStrArray?.indexOf(option.id) > -1 , onChange));
        });
        return checkboxContainer;
    }
  
    function embyCheckbox({ id, name, label, value }, checked = false, onChange) {
        const checkboxLabel = document.createElement('label');
        checkboxLabel.classList.add('emby-checkbox-label');
        checkboxLabel.setAttribute('style', 'width: auto;');
        const checkbox = document.createElement('input', { is: 'emby-checkbox' });
        checkbox.setAttribute('is', 'emby-checkbox');
        checkbox.setAttribute('type', 'checkbox');
        checkbox.setAttribute('id', id);
        checkbox.setAttribute('name', name);
        checkbox.setAttribute('value', value);
        checkbox.checked = checked;
        checkbox.classList.add('emby-checkbox', 'chkEnableLiveTvAccess');
        if (typeof onChange === 'function') {
            checkbox.addEventListener('change', e => onChange(e.target.checked));
        }
        const span = document.createElement('span');
        span.setAttribute('class', 'checkboxLabel');
        span.innerHTML = label;
        checkboxLabel.appendChild(checkbox);
        checkboxLabel.appendChild(span);
        return checkboxLabel;
    }

    /** props: {id: 'textareaId',value: '', rows: 10,style: '', styleResize:''|'vertical'|'horizontal'
     *      , style: '', readonly: false} for setAttribute(key, value)
     * function will not setAttribute
     */
    function embyTextarea(props, onBlur) {
        const defaultProps = { rows: 10, styleResize: 'vertical', readonly: false };
        props = { ...defaultProps, ...props };
        const textarea = document.createElement('textarea', { is: 'emby-textarea' });
        Object.entries(props).forEach(([key, value]) => {
            if (typeof value !== 'function' && key !== 'readonly' 
                && key !== 'styleResize' && key !== 'value') { textarea.setAttribute(key, value); }
        });
        textarea.className = 'txtOverview emby-textarea';
        textarea.readOnly = props.readonly;
        textarea.style.resize = props.styleResize;
        textarea.value = props.value;
        if (typeof onBlur === 'function') { textarea.addEventListener('blur', onBlur); }
        return textarea;
    }

    /** props: {id: 'slider id', labelId: 'label id', ...} will return to the callback
    *   orient: 'vertical' | 'horizontal' 垂直/水平 
    */
    function embySlider(props = {}, options = {}, onChange, onSliding) {
        const defaultOpts = {
            min: 0.1, max: 3, step: 0.1, orient: 'horizontal',
            'data-bubble': false, 'data-hoverthumb': true , style: '',
        };
        options = { ...defaultOpts, ...options };
        const slider = document.createElement('input', { is: 'emby-slider' });
        slider.setAttribute('type', 'range');
        if (props.id) { slider.setAttribute('id', props.id); }
        Object.entries(options).forEach(([key, value]) => slider.setAttribute(key, value));
        if (options.value) { slider.setValue(options.value);}
        // other EventListeners : 'beginediting'(every step), 'endediting'(end of tap/swipe)
        if (typeof onChange === 'function') {
            // Trigger after end of tap/swipe
            slider.addEventListener('change', e => onChange(e.target.value, props));
        }
        if (typeof onSliding === 'function') {
            // when clicking/sliding, trigger every step
            slider.addEventListener('input', e => onSliding(e.target.value, props));
        }
        // 以下兼容旧版本emby,控制器操作锁定滑块焦点
        slider.addEventListener('keydown', e => {
            const orient = slider.getAttribute('orient') || 'horizontal';
            if ((orient === 'horizontal' && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) ||
                (orient === 'vertical' && (e.key === 'ArrowUp' || e.key === 'ArrowDown'))) {
                e.stopPropagation();
            }
        });
        return slider;
    }

    /**
     * see: ../web/modules/dialog/dialog.js
     * opts have type props: unknown
     * dialog have buttons prop: [{ type: 'submit', id: 'cancel', name:'取消', description: '无操作', href: 'index.html',  }]
     */
    async function embyDialog(opts = {}) {
        const defaultOpts = { text: '', title: '', timeout: 0, html: '', buttons: [] };
        opts = { ...defaultOpts, ...opts };
        return require(['dialog']).then(items => items[0]?.(opts))
            .catch(error => { console.log('点击弹出框外部取消: ' + error) });
    }

    function closeEmbyDialog() {
        document.querySelector('.formDialogFooterItem').dispatchEvent(new Event('click'));
    }

    function embyALink(text, href) {
        const aEle = document.createElement('a');
        aEle.textContent = text;
        aEle.href = href;
        aEle.target = '_blank';
        aEle.className = 'button-link button-link-color-inherit button-link-fontweight-inherit emby-button';
        return aEle;
    }

    // see: ../web/modules/common/dialogs/alert.js
    async function embyAlert(opts = {}) {
        const defaultOpts = { text: '', title: '', timeout: 0, html: ''};
        opts = { ...defaultOpts, ...opts };
        return require(['alert']).then(items => items[0]?.(opts))
            .catch(error => { console.log('点击弹出框外部取消: ' + error) });
    }

    // see: ../web/modules/toast/toast.js, 严禁滥用,因遮挡画面影响体验
    async function embyToast(opts = {}) {
        const defaultOpts = { text: '', secondaryText: '', icon: '', iconStrikeThrough: false};
        opts = { ...defaultOpts, ...opts };
        return require(['toast'], toast => toast(opts));
    }

    function getValueOrInvoke(option, keyOrFunc) {
        return typeof keyOrFunc === 'function' ? keyOrFunc(option) : option[keyOrFunc];
    }

    function getSettingsJson(space = 4) {
        return JSON.stringify(Object.fromEntries(Object.entries(lsKeys).map(
            ([key, value]) => [value.id, lsGetItem(value.id)])), null, space);
            // ([key, value]) => [value.id, { value: lsGetItem(value.id), name: value.name }])), null, space);
    }

    // 缓存相关方法
    function lsGetItem(id) {
        const key = lsGetKeyById(id);
        if (!key) { return null; }
        const defaultValue = lsKeys[key].defaultValue;
        const item = localStorage.getItem(id);
        if (item === null) { return defaultValue; }
        if (Array.isArray(defaultValue)) { return JSON.parse(item); }
        if (Array.isArray(defaultValue) || typeof defaultValue === 'object') { return JSON.parse(item); }
        if (typeof defaultValue === 'boolean') { return item === 'true'; }
        if (typeof defaultValue === 'number') { return parseFloat(item); }
        return item;
    }
    function lsCheckOld(id, value) {
        return JSON.stringify(lsGetItem(id)) === JSON.stringify(value);
    }
    function lsCheckSet(id, value) {
        if (lsCheckOld(id, value)) { return false; }
        lsSetItem(id, value);
        return true;
    }
    /** 批量设置缓存
     * @param {object} keyValues - 键值对对象,如 {key1: value1, key2: value2}
     * @returns {boolean} - 是否有更新
     */
    function lsMultiSet(keyValues, needCheck = true) {
        if (needCheck) {
            return Object.entries(keyValues).reduce((acc, [id, value]) => (acc || lsCheckSet(id, value)), false);
        } else {
            Object.entries(keyValues).forEach(([key, value]) => lsSetItem(key, value));
        }
    }
    function lsSetItem(id, value) {
        if (!lsGetKeyById(id)) { return; }
        let stringValue;
        if (Array.isArray(value)) {
            stringValue = JSON.stringify(value);
        } else if (typeof value === 'object') {
            stringValue = JSON.stringify(value);
        } else {
            stringValue = value;
        }
        localStorage.setItem(id, stringValue);
    }
    function lsGetKeyById(id) {
        return Object.keys(lsKeys).find(key => lsKeys[key].id === id);
    }

    /**
     * @param {number} [timeout=10000] - 超时时间,默认10秒,0则不设置超时
     * @param {number} [interval=check_interval] - 检查间隔,默认200ms
     * */
    function waitForElement(selector, callback, timeout = 10000, interval = check_interval) {
        let intervalId = null;
        let timeoutId = null;
        function checkElement() {
            console.log(`waitForElement: checking element[${selector}]`);
            const element = document.querySelector(selector);
            if (element) {
                clearInterval(intervalId);
                clearTimeout(timeoutId);
                callback(element);
            }
        }
        intervalId = setInterval(checkElement, interval);
        window.ede.destroyIntervalIds.push(intervalId);
        if (timeout > 0) {
            timeoutId = setTimeout(() => {
                clearInterval(intervalId);
                console.log(`waitForElement: unable to find element[${selector}], timeout: ${timeout}`);
            }, timeout);
        }
    }

    // from emby videoosd.js bindToPlayer events, warning: not dom event
    function playbackEventsOn(eventsMap, data = {}) {
        require(['playbackManager', 'events'], (playbackManager, events) => {
            if (!playbackManager || !events) { return; }
            const player = playbackManager.getCurrentPlayer();
            const playState = playbackManager.getPlayerState().PlayState;
            Object.entries(eventsMap).forEach(([eventName, fn]) => {
                events.off(player, eventName, fn);
                events.on(player, eventName, (e, ...args) => {
                    fn(e, ...args, { playbackManager, events, player, playState, ...data });
                    // 禁止在 timeupdate 回调中打印日志及复杂操作,每秒都会触发一次
                    if ('timeupdate' != eventName) {
                        console.log(`playbackEventsOn ${eventName} event: ${fn.name}`);
                    }
                });
            });
        });
    }

    function initH5VideoAdapter() {
        let _media = document.querySelector(mediaQueryStr);
        if (_media) { return; }
        console.log('页面上不存在 video 标签,适配器处理开始');
        _media = document.createElement('video');
        _media.style.display = 'none';
        _media.id = eleIds.h5VideoAdapter;
        _media.classList.add('htmlvideoplayer', 'moveUpSubtitles');
        document.body.prepend(_media);

        _media.play();
        window.ede.destroyIntervalIds.push(setInterval(() => { _media.currentTime += 100 / 1e3 }, 100));

        playbackEventsOn({
            // conver to seconds from Ticks
            'timeupdate': (e, data) => {
                _media.currentTime = data.playbackManager.currentTime(data.player) / 1e7;
                // playbackRate 同步依赖至少 100ms currentTime 变更
                _media.playbackRate = data.playbackManager.getPlayerState().PlayState.PlaybackRate ?? 1;
            },
            'pause': (e, data) => {
                _media.dispatchEvent(new Event('pause'));
                window.ede.destroyIntervalIds.map(id => clearInterval(id));
                window.ede.destroyIntervalIds = [];
            },
            'unpause': (e, data) => {
                _media.dispatchEvent(new Event('play'));
                // 只有老版本 Emby Theater 播放首次会进来,所以上面初始化重新添加一次定时器
                window.ede.destroyIntervalIds.push(setInterval(() => { _media.currentTime += 100 / 1e3 }, 100));
            },
        });
        console.log('已创建虚拟 video 标签,适配器处理正确结束');
    }

    function beforeDestroy() {
        // 此段销毁不重要,可有可无,仅是规范使用,清除弹幕,但未销毁 danmaku 实例
        window.ede.danmaku?.clear();
        window.ede.appLogAspect?.destroy();
        window.ede.appLogAspect = null;
        // 销毁弹幕按钮容器简单,双 mediaContainerQueryStr 下免去 DOM 位移操作
        document.getElementById(eleIds.danmakuCtr)?.remove();
        // 销毁可能残留的定时器
        window.ede.destroyIntervalIds.map(id => clearInterval(id));
        window.ede.destroyIntervalIds = [];
        // 退出播放页面重置轴偏秒
        lsSetItem(lsKeys.timelineOffset.id, lsKeys.timelineOffset.defaultValue);
    }

    // emby/jellyfin CustomEvent. see: https://github.com/MediaBrowser/emby-web-defaultskin/blob/822273018b82a4c63c2df7618020fb837656868d/nowplaying/videoosd.js#L698
    document.addEventListener('viewshow', function (e) {
        console.log('viewshow', e);
        if (e.detail.type === 'video-osd') {
            window.ede = new EDE();
            if (lsGetItem(lsKeys.consoleLogEnable.id)) {
                window.ede.appLogAspect = new AppLogAspect().init();
            }
            initUI();
            initH5VideoAdapter();
            // loadDanmaku(LOAD_TYPE.INIT);
            initListener();
        }
    });
    document.addEventListener('viewbeforehide', e => e.detail.type === 'video-osd' && beforeDestroy());

})();


66f70398d363d.png

  1. 打开某部番剧看到下图选项后即表示开启成功
    66f7039c615a2.png