Youtube smooth floating chat 丝滑悬浮弹幕

Youtube floating chat 悬浮弹幕,丝滑滚动 # Danmaku barrage bullet curtain

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Youtube smooth floating chat 丝滑悬浮弹幕
// @namespace    67373tools
// @version      0.1.32
// @description  Youtube floating chat 悬浮弹幕,丝滑滚动 # Danmaku barrage bullet curtain
// @author       XiaoMIHongZHaJi
// @match        https://www.youtube.com/*
// @match        https://www.twitch.tv/embed/*/chat?parent=*
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        unsafeWindow
// @license MIT
// ==/UserScript==

// ❤️ 广告:欢迎收看陈一发儿直播:https://67373.net
// 如果有 bug,在上面网站也可以找到反馈联系方式

// ✴️ 通用
localStorage.removeItem('danmuParams'); // 清除旧版数据;

let videoDoc = {}, configs;
if (!document.URL.startsWith('https://www.twitch.tv')) videoDoc = parent.document.querySelector('video').ownerDocument;
const defaultPosition =
  { top: 88, left: 58, maxHeight: 528, width: 528, fontSize: 15, gap: 3, transparent: 0.58 };
const defaultConfigs = {
  ...defaultPosition, showMode: 0, singleLine: false, /* 这里暂时不用但不要删除:*/fullLine: false,
  speed: 1, language: 'English', twitchLink: '', isTwitchActive: true,
  focusNames: [], highlightNames: [], blockNames: [],
  isFocusNames: false, isHighlightNames: false, isBlockNames: false,
};

const text = {
  English: {
    nextLanguage: '中文',
    menuSetting: 'Settings',
    menuResetPosition: 'Reset location',
    menuResetExceptNames: 'Reset all settings except username',
    menuResetAll: 'Reset all settings',
    menuResetAllConfirm: 'All settings will be reset, names lists will be cleared, continue?',
    modes: ['Show all', 'Short name', 'No name', 'Hide all'],
    fontSize: 'Font',
    speed: 'Speed',
    gap: 'Gap',
    transparency: 'Transparency',
    height: 'Height',
    settings: 'Settings',
    singleLine: 'Single Column',
    fullLine: 'Full line', // wasted
    twichTip: 'Twitch chat merge',
    twitchLinkPlaceholder: 'Enter Twitch room link',
    twitchUrlMatchAlert: 'The URL match failed. Please enter a valid Twitch room address.',
    focusMode: `Filter: Only show chats according to following rules`,
    highlightMode: `Highlight: highlight chats according to following rules`,
    blockMode: `Block: Chats that matching following rules will be blocked`,
    nameTip: `<p>Each line is a regular expression. By default, it filters usernames.
    <code>[chat]</code> indicates filtering chat content,
    <code>[off]</code> indicates that the rule is inactive.</p>
    <br/><p>Common filter examples:</p><ul>
      <li><code class="danmu-name-tip-code">chenyifaer</code> filters usernames containing "chenyifaer";</li>
      <li><code class="danmu-name-tip-code">^chenyifaer$</code>
        filters usernames exactly matching "chenyifaer";</li>
      <li><code class="danmu-name-tip-code">[chat]chenyifaer</code> filters messages containing "chenyifaer";</li>
      <li><code class="danmu-name-tip-code">[off]chenyifaer</code> indicates that this rule is not active;</li>
    </ul><br/><p>If you don't know how to write regular expressions, you can ask ChatGPT ~</p>`,
    cpoiedTip: 'Copied',
    popBoardConfirm: 'Close',
  },
  "中文": {
    nextLanguage: 'English',
    menuSetting: '设置',
    menuResetPosition: '重置位置',
    menuResetExceptNames: '重置除了名字列表外的所有设置',
    menuResetAll: '重置所有设置',
    menuResetAllConfirm: '所有设置都会重置,名字列表会被清空,是否继续',
    modes: ['全显示', '短用户名', '无用户名', '全隐藏'/*「全隐藏」这三个字不要改 */],
    fontSize: '字号',
    speed: '速度',
    gap: '间隔',
    transparency: '透明度',
    height: '高度',
    settings: '设置',
    singleLine: '单列',
    fullLine: '满行', // 弃用
    twichTip: 'Twitch 弹幕融合',
    twitchLinkPlaceholder: '请填入 Twitch 直播间链接',
    twitchUrlMatchAlert: '网址匹配失败,请输入正确的 Twitch 房间地址',
    focusMode: `过滤:只显示以下规则过滤弹幕`,
    highlightMode: `高亮:根据以下规则高亮弹幕`,
    blockMode: `屏蔽:屏蔽符合以下规则的弹幕`,
    nameTip: `<p>每行一条正则表达式。默认筛选用户名,
      <code>[chat]</code> 表示筛选弹幕,<code>[off]</code> 表示不生效。</p>
      <br/><p>常用筛选举例:</p>
      <ul><li><code class="danmu-name-tip-code">陈一发儿</code> 筛选包含「陈一发儿」的用户名;</li>
          <li><code class="danmu-name-tip-code">^陈一发儿$</code> 筛选等于「陈一发儿」的用户名;</li>
          <li><code class="danmu-name-tip-code">[chat]陈一发儿</code> 筛选包含「陈一发儿」的弹幕;</li>
          <li><code class="danmu-name-tip-code">[off]陈一发儿</code> 表示这条规则不生效;</li>
      </ul><br/><p>如果不会写正则表达式可以问 ChatGPT ~</p>`,
    cpoiedTip: '已复制',
    popBoardConfirm: '关闭',
  }
};

const twitchLinkEmbed = (link) => {
  if (link === '') return '';
  try {
    let name = link.match(/twitch\.tv\/(?:popout\/|embed\/|)([^\/?#]+)/)[1];
    return `https://www.twitch.tv/embed/${name}/chat?parent=www.youtube.com`;
  } catch {
    alert(text[configs.language].twitchUrlMatchAlert);
  };
};
// https://www.twitch.tv/popout/jinnytty/chat?popout=
// https://www.twitch.tv/embed/jinnytty/chat?parent=iframetester.com
// https://www.twitch.tv/jinnytty

function deepCopy(a) {
  try {
    return structuredClone(a);
  } catch (e) {
    console.log(e);
    return JSON.parse(JSON.stringify(a));
  }
};

getLocal();
function getLocal() {
  configs = deepCopy(defaultConfigs);
  const configsStr = localStorage.getItem('danmuConfigs') || '{}';
  configs = Object.assign({}, configs, JSON.parse(configsStr));
};
for (let key in configs) { // 删除旧版本的 key
  if (!(key in defaultConfigs)) delete configs[key];
};

setLocal();
function setLocal(params) {
  localStorage.setItem('danmuConfigs', JSON.stringify(Object.assign(configs, params)));
};

// ✴️ 主页面
if (location.href.startsWith('https://www.youtube.com/watch?v=')
  || location.href.startsWith('https://www.youtube.com/live/')) {
  let danmuEle;
  setStyle(); // 加 css 到 header
  danmuEleInit();
  function danmuEleInit() {
    let timer = setInterval(() => {
      if (document.querySelector('#danmu-ele')) {
        clearInterval(timer);
        return;
      }
      if (document.querySelector('#chat-container iframe')) { // 检测到iframe,说明不是普通视频页面
        try {
          danmuEle = getDanmuEle();
          danmuEle.danmuurl = videoDoc.URL;
          document.querySelector('body').appendChild(danmuEle)
        } catch (e) { console.log(e) };
      }
    }, 888);
    setTimeout(() => {
      clearInterval(timer);
    }, 28888) // 半分钟没检测到iframe,放弃
  }
  // 监听页面跳转事件
  (function (history) {
    const pushState = history.pushState;
    const replaceState = history.replaceState;
    function onStateChange(event) {
      let danmuEle = document.getElementById('danmu-ele');
      if (!danmuEle) {
        danmuEleInit();
      } else if (danmuEle.danmuurl != document.URL) danmuEle.parentNode.removeChild(danmuEle);
    };
    window.addEventListener('popstate', onStateChange);
    window.addEventListener('hashchange', onStateChange);
    history.pushState = function (state) {
      const result = pushState.apply(history, arguments);
      onStateChange({ state });
      return result;
    };
    history.replaceState = function (state) {
      const result = replaceState.apply(history, arguments);
      onStateChange({ state });
      return result;
    };
    const observer = new MutationObserver(() => {
      if (document.location.href !== observer.lastHref) {
        observer.lastHref = document.location.href;
        onStateChange({});
      }
    });
    observer.observe(document, { subtree: true, childList: true });
    observer.lastHref = document.location.href;
  })(window.history);

  // 监听 postMessage
  window.addEventListener('message', (event) => {
    if (!danmuEle) return;
    if (event.origin === 'https://www.twitch.tv') {
      let username = '';
      for (let i in event.data) {
        if (event.data[i][0] === 'username') {
          username = event.data[i][1];
          break;
        };
      };
      let content = '';
      for (let i in event.data) {
        if (event.data[i][0] === 'text') content += event.data[i][1];
      };
      let el = document.createElement('div');
      el.className = 'danmu-item';
      let matchChatRet = matchChat(username, content);
      if (matchChatRet.isNoShow) return;
      if (matchChatRet.isHighlight) el.className += ' danmu-highlight ';
      el.innerHTML += `<table><tbody><tr><td class="first-td"></td><td class="second-td"></td></tr></tbody></table>`
      for (let i in event.data) {
        switch (event.data[i][0]) {
          case 'img': {
            if (i == 0) {
              el.querySelector('.first-td').innerHTML += `<img src="${event.data[i][1]}">`
            } else {
              el.querySelector('.second-td').innerHTML += `<img src="${event.data[i][1]}">`
            }
            break;
          }
          case 'username':
            el.querySelector('.second-td').innerHTML += `<span class="danmu-username-long">${event.data[i][1]}:</span>`;
            el.querySelector('.second-td').innerHTML += `<span class="danmu-username-short">${event.data[i][1].substring(0, 1)}:</span>`;
            break;
          case 'text':
            el.querySelector('.second-td').innerHTML += event.data[i][1];
            break;
          default:
            break;
        };
      };
      danmuEle.querySelector('#danmu-content').appendChild(el);
      checkHeight(danmuEle);
    };
  });
};

// ✴️ YouTube chat iframe 页面
if (location.href.startsWith('https://www.youtube.com/live_chat')) {
  let danmuEle = parent.document.querySelector("#danmu-ele");
  if (document.readyState == "complete" || document.readyState == "loaded"
    || document.readyState == "interactive") {
    main();
  } else document.addEventListener("DOMContentLoaded", main);
  setInterval(getLocal, 1888); // 父页面操作的时候,很容易数据不同步。
  function main() {
    let config = { childList: true, subtree: true };
    let observer = new MutationObserver(mutations => {
      // 【】 if (mutations.length > 500) return; // 防止一次大量加载
      mutations.forEach(mutation => {
        mutation.addedNodes.forEach(node => {
          if (!danmuEle) {
            danmuEle = parent.document.querySelector("#danmu-ele");
            if (!danmuEle) return;
          };
          if (node.nodeType !== Node.ELEMENT_NODE) return;
          if (!['yt-live-chat-text-message-renderer', 'yt-live-chat-paid-message-renderer', 'yt-live-chat-paid-sticker-renderer']
            .includes(node.tagName.toLowerCase())) return;
          if (mutations.length > 500 && node.getBoundingClientRect().right == 0) return;
          let el = digestYtChatDom(node);
          if (!el) return;
          danmuEle.querySelector('#danmu-content').appendChild(el);
          checkHeight(danmuEle);
        });
      });
    });
    let timer = setInterval(() => {
      let ytbChatEle = document.querySelector('#contents.style-scope.yt-live-chat-app');
      if (!ytbChatEle) return;
      clearInterval(timer);
      observer.observe(ytbChatEle, config);
    }, 888);
  };
};
// 检查不存在的元素
// getComputedStyle(a).display flex 无法判断
// getComputedStyle(a).visibility visible 无法判断
// a.getBoundingClientRect().width 911 无法判断
// a.getBoundingClientRect().height 32 无法判断
// a.getBoundingClientRect(). top right bottom left 0 正常是只有 left=0

// ✴️ Twitch chat iframe 页面
if (document.URL.match(/https:\/\/www\.twitch\.tv\/embed\/[^\/]+\/chat\?parent=/)) {
  console.log('进入了 Twitch 的 chat iframe 页面:', document.URL);
  let config = { childList: true, subtree: true };
  let timer = setInterval(() => {
    let watchEl = document.querySelector('.simplebar-content');
    if (!watchEl) return;
    clearInterval(timer);
    observer.observe(watchEl, config);
  }, 888);
  let observer = new MutationObserver(mutations => {
    mutations.forEach(mutation => {
      mutation.addedNodes.forEach(node => {
        if (node.nodeType !== 1) return;
        if (!node.parentElement.parentElement.className == 'simplebar-content') return;
        let allNodes = extractTextNodes(node);
        if (allNodes.length === 0) return;
        unsafeWindow.parent.postMessage(allNodes, 'https://www.youtube.com');
      });
    });
  });

  function extractTextNodes(node) {
    let allNodes = [], username = '', ignoreName = '', imgCount = 0;
    function traverseNodes(node) {
      if (node.nodeType === Node.TEXT_NODE) {
        allNodes.push(['text', node.textContent.trim()]); // 仅保存非空文本
      } else if (node.nodeType === Node.ELEMENT_NODE) {
        if (node.classList.contains('chat-author__display-name') && node.tagName.toLowerCase() === 'span') {
          username = node.textContent.trim();
        };
        if (node.classList.contains('chat-author__intl-login') && node.tagName.toLowerCase() === 'span') {
          ignoreName = node.textContent.trim();
        };
        if (node.tagName.toLowerCase() === 'img') {
          allNodes.push(['img', node.src]);
          imgCount++;
        }
        Array.from(node.childNodes).forEach(child => traverseNodes(child));
      };
    };
    traverseNodes(node);
    if (!username) {
      username = ignoreName;
      ignoreName = '';
    };
    if (allNodes.length == imgCount) return [];
    if (allNodes[0][1].match(/\d{2}:\d{2}/)) allNodes.shift(); //.splice(0, 1);
    if (allNodes[0][0] !== 'img') allNodes.unshift(['img', 'https://assets.twitch.tv/assets/favicon-32-e29e246c157142c94346.png']);
    // console.log(JSON.stringify(allNodes, null, 2));
    for (let i in allNodes) {
      if (allNodes[i][1] === ignoreName) {
        allNodes.splice(i, 1);
        break;
      };
    };
    for (let i in allNodes) {
      if (allNodes[i][1] === ':') {
        allNodes[i] = ['colon', ':'];
        break;
      };
    };
    for (let i in allNodes) {
      if (allNodes[i][1] === username) {
        if (username.length >= 21) username = username.substring(0, 18) + '...';
        allNodes[i] = ['username', username];
        break;
      };
    };
    return allNodes;
  };
};

// ⬜️ 获取聊天内容 小米(改
function matchChat(username, content) {
  let ret = {};
  function isMatched(username, content, reg) {
    if (reg.includes('[off]')) return;
    let str = username;
    if (reg.includes('[chat]')) str = content;
    reg = new RegExp(reg.replace('[chat]', ''), 'i');
    if (str.match(reg)) return true;
  };
  if (configs.isFocusNames) {
    ret.isNoShow = true;
    configs.focusNames.forEach(reg => {
      if (isMatched(username, content, reg)) ret.isNoShow = false;
    })
  }
  if (configs.isHighlightNames) {
    configs.highlightNames.forEach(reg => {
      if (isMatched(username, content, reg)) ret.isHighlight = true;
    })
  }
  if (configs.isBlockNames) {
    configs.blockNames.forEach(reg => {
      if (isMatched(username, content, reg)) ret.isNoShow = true;
    })
  };
  return ret;
};

function digestYtChatDom(dom) {
  const userPhotoElement = dom.querySelector("#author-photo #img");
  const userphoto = userPhotoElement ? userPhotoElement.outerHTML : '';
  const contentElement = dom.querySelector("#message");
  let content = contentElement ? contentElement.innerHTML : '';
  let usernameElement = dom.querySelector("#author-name");
  let username = usernameElement ? usernameElement.innerHTML : ''; // 这里参照原有代码,就不改了
  if (!username) return;
  username = username.match(/(.*?)</)[1];
  let el = videoDoc.createElement('div');
  el.className = 'danmu-item';
  let matchChatRet = matchChat(username, content);
  if (matchChatRet.isNoShow) return;
  if (matchChatRet.isHighlight) el.className += ' danmu-highlight ';
  let color = '';
  let shortUsername = username.substring(0, 1);
  if (username.length >= 21) username = username.substring(0, 18) + '...';
  if (dom.querySelector("#card") && dom.querySelector("#purchase-amount")) {
    username = "(SC) " + username;
    try {
      let price = dom.querySelector("#purchase-amount").innerText;
      content = `${price} ${content}`;
    } catch { };
    color = getComputedStyle(dom).getPropertyValue("--yt-live-chat-paid-message-primary-color");
    color = `style="color: ${color}"`;
  };
  if (dom.querySelector("#card") && dom.querySelector("#price-column")) {
    username = "(SC) " + username;
    color = getComputedStyle(dom.querySelector('#card')).backgroundColor;
    content = dom.querySelector("#price-column").innerText;
    color = `style="color: ${color}"`;
  }
  el.innerHTML += `<table><tbody><tr><td class="first-td"></td><td class="second-td"></td></tr></tbody></table>`;
  el.querySelector('.first-td').innerHTML += `${userphoto}`;
  let separator = content ? ':' : '';
  el.querySelector('.second-td').innerHTML += `<span class="danmu-username-long" ${color}>${username}<span class="danmu-badge">`
    + `</span>${separator}</span>`;
  el.querySelector('.second-td').innerHTML +=
    `<span class="danmu-username-short" ${color}>${shortUsername}<span class="danmu-badge">`
    + `</span>${separator}</span>`;
  el.querySelector('.second-td').innerHTML += `<span class="danmu-text" ${color}>${content}</span>`;
  setTimeout(() => {
    if (el.querySelector('img')?.src?.startsWith('data')) {
      el.querySelector('img').src = dom.querySelector("#author-photo #img").src;
    }
    try {
      let badge = [dom.querySelector("yt-icon div")?.cloneNode(true)];
      let path = badge[0]?.querySelector('path');
      if (path && path.getAttribute('d')?.startsWith('M9.64589146,7.05569719')) {
        switch (0) {
          case 0: {
            badge[0].style.width = '1em';
            badge[0].style.display = 'inline-block';
            badge[0].style.color = 'lightyellow';
            let badeges = el.querySelectorAll('.danmu-badge');
            for (let i = 0; i < badeges.length; i++) {
              badeges[i].appendChild(badge[i]);
              badge[i + 1] = badge[0].cloneNode(true);
            };
            break;
          }
          case 1:
            el.querySelector('.danmu-badge').innerText = '🔧';
            break;
        }
      }
    } catch (e) { };
  }, 588)
  return el;
};

// ⬜️ 动态滑动弹幕逻辑,略有些复杂
// css 的 transition 方案:弃用,自动设置时间,但多个元素一起变会卡
/* height 和 tramsform 方案:弃用,多个 div 位于一行时,高度设置没用,
    还是会挤住下面的元素,可能是 div 内部元素的问题,也可能是多个元素一起变化时就是会卡 */
// margin 的方案:弃用,多个元素还是会卡,看来多个元素一起变化的话无论如何都会卡
// 更好的方案:在外面再套一层 div,避免底端上移。但先不改了。
function linesInfo(danmuEle, ifFirstLine, ifSecondLine) {
  let children = danmuEle.querySelectorAll('.danmu-item');
  if (children.length == 0) return;
  let lastChild = children[children.length - 1];
  let lastChildRect = lastChild.getBoundingClientRect();
  let firstChildRect = children[0].getBoundingClientRect();
  let margin = parseFloat(getComputedStyle(children[0]).margin);
  let baseHeight = margin + firstChildRect.height;
  let diff = lastChildRect.bottom - danmuEle.getBoundingClientRect().bottom;
  let danmuCtrlRect = danmuEle.querySelector('#danmu-ctrl').getBoundingClientRect();
  let distance = firstChildRect.bottom - danmuCtrlRect.bottom;
  let isOverlap1 = firstChildRect.top < danmuCtrlRect.bottom;
  let firstLine, secondLine;
  if (ifFirstLine) {
    firstLine = [children[0]];
    for (let i = 1; i < children.length; i++) {
      if (children[i].getBoundingClientRect().top <= children[0].getBoundingClientRect().top + 3) {
        firstLine[i] = children[i];
      } else {
        if (ifSecondLine) {
          secondLine = [children[i]];
          for (let j = i + 1; j < children.length; j++) {
            if (children[j].getBoundingClientRect().top <= children[i].getBoundingClientRect().top + 3) {
              secondLine[j - i] = children[j];
            } else break;
          };
        }
        break;
      };
    }
  }
  return { notEmpty: true, children, distance, isOverlap1, diff, margin, baseHeight, firstLine, secondLine };
};
// -----------
function removeCoverdTops(danmuEle, force) {
  let l = linesInfo(danmuEle, true, false);
  try {
    while (l?.distance < 0 || (force && l?.firstLine) || l?.diff > 3 * l?.baseHeight) {
      force = false;
      for (let i = 0; i < l.firstLine.length; i++) {
        l.firstLine[i].parentNode.removeChild(l.firstLine[i]);
      };
      let contentEl = danmuEle.querySelector('#danmu-content');
      contentEl.style.marginTop =
        Math.min(0, parseFloat(getComputedStyle(contentEl).marginTop) + l.baseHeight) + 'px';
      l = linesInfo(danmuEle, true, false);
    };
  } catch (e) { console.log(e) };
  return l;
};
// 检查高度设计:
// 检查间隔 1/25 秒 timesVar
// 检查是否有完全覆盖的弹幕并删除
// 如果有 overlap 或 底部超框,说明需要调整
// -----------
// 移动基础1:下边超出距离 diff
// 移动基础2:第一行元素剩余距离 distance
// 本次移动最终目标:diff 和 distance 的最大值 move
// 本次移动阶段性目标:move * timesVar
// 移动基础3:最小值:基础高度 * timesVar,但不能超过 move
// 移动基础3:上限:0.8高度,但不能超过 move
// -----------
// 移动完后进入下一次检查
videoDoc.danmuObj = { isCheckingHeight: undefined };
const timesVar = 1 / 28;
function checkHeight(danmuEle) {
  if (videoDoc.danmuObj.isCheckingHeight) return;
  videoDoc.danmuObj.isCheckingHeight = true;
  if (getComputedStyle(danmuEle.querySelector('#danmu-content')).display == 'none') {
    let items = danmuEle.querySelectorAll('.danmu-item');
    for (let i = 0; i < items.length - 288; i++) {
      danmuEle.removeChild(items[i]);
    }
    videoDoc.danmuObj.isCheckingHeight = false;
    return;
  };
  try {
    // 检查是否有完全覆盖的弹幕并删除
    let l = removeCoverdTops(danmuEle);
    // 如果有 overlap 或 底部超框,说明需要调整
    if (!l) { videoDoc.danmuObj.isCheckingHeight = false; return; };
    if (!l.isOverlap1 && l.diff <= 0) { videoDoc.danmuObj.isCheckingHeight = false; return; };
    // 移动基础
    let move = Math.max(l.diff, l.distance);
    let currentMove = move * timesVar;
    currentMove = Math.max(l.baseHeight * timesVar, currentMove);
    // currentMove = Math.min(l.baseHeight * 0.8, currentMove); 这里限制了最高速度,现在解开
    currentMove = Math.min(move, currentMove);
    let opacity = l.distance / l.baseHeight;
    l.firstLine.forEach(node => {
      try { node.style.opacity = opacity } catch (e) { console.log(e) };
    });
    let contentEl = danmuEle.querySelector('#danmu-content');
    let currentTop = parseFloat(getComputedStyle(contentEl).marginTop);
    contentEl.style.marginTop = `${currentTop - currentMove}px`;
  } catch (e) {
    videoDoc.danmuObj.isCheckingHeight = false;
    console.log(e);
  };

  setTimeout(() => {
    videoDoc.danmuObj.isCheckingHeight = false;
    checkHeight(danmuEle);
  }, timesVar * 0.8 / configs.speed * 1000);
};

// ⬜️ 样式初始化(加到 head)
function styleCalc() {
  let danmuItemPaddingTop = configs.gap;
  let danmuItemMargin = configs.gap / 5 + 0.5;
  return {
    danmuItemPaddingTop, danmuItemMargin,
    danmuItemHeight: configs.fontSize + danmuItemPaddingTop + danmuItemMargin
  };
};

function setStyle() {
  let floatDanmuStyle = videoDoc.querySelector('#float-danmu-style');
  if (!floatDanmuStyle) {
    floatDanmuStyle = videoDoc.createElement('style');
    floatDanmuStyle.id = 'float-danmu-style';
    document.head.appendChild(floatDanmuStyle);
  }
  let danmuItemDisplay = configs.singleLine ? 'block' : (configs.fullLine ? 'inline' : 'inline-block');
  let danmuItemLineHeight = (!configs.singleLine && configs.fullLine)
    ? `line-height: ${1.28 * configs.fontSize + 2.18 * configs.gap}px` : '';
  let baseStyle = `
  .danmu-highlight {
    border: solid 1.8px rgba(255, 191, 0, 1.8);
    box-shadow: inset 0 0 ${configs.gap + configs.fontSize / 2}px rgba(255, 191, 0, 0.8); /* 内发光效果 */
  }
  #danmu-ele {
    position: absolute;
    color: white;
    height: auto;
    z-index: 911;
    top: ${configs.top}px;
    left: ${configs.left}px;
    width: ${configs.width}px;
  }
  #danmu-ctrl {
    z-index: 1013;
    position: relative;
    background-color: rgba(0,0,0,0.5);
    border: solid white 0.1px;
    padding: 2.8px;
    font-size: 12.8px;
  }
  #danmu-pop-board {
    max-width:100vw;
    overflow-x: auto;
    z-index: 418094;
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    display: none;
    background-color: #f0f0f0;
    border: 1px solid #ccc;
  }
  #danmu-pop-board-in {
    min-width: 788px;
    padding: 18px;
    color: black;
    font-size: 1.18em;
  }
  #danmu-pop-board .small-setting {
    display: inline-block;
    padding: 0.5em;
    background-color: #fafafa;
    border-radius: 0.5em;
    white-space: nowrap;
  }
  #danmu-name-container {
    margin:0.5em 0 1em 0;
    display: flex;
    gap: 1em;
    height: 288px;
    background-color: #fafafa;
    padding: 1em 0.8em;
    border-radius: 0.5em
  }
  #danmu-name-container div {
    width: 100%
  }
  #danmu-name-container textarea {
    width: 97%;
    height: 86%
  }
  #danmu-name-container label {
    display: inline-block;
    height: 3em;
  }
  #danmu-pop-board ul {
    list-style-type: disc;
    margin-left: 1.8em
  }
  #danmu-ele code {
    color: Brown;
  }
  #danmu-content {
    font-size: 0px;
    max-height: ${configs.maxHeight}px;
    height: auto;
  }
  .danmu-username-long, .danmu-username-short {
    color: rgb(200,200,200);
  }
  .danmu-item {
    font-size: ${configs.fontSize}px;
    width: fit-content;
    background-color: rgba(0, 0, 0, ${configs.transparent});
    border-radius: ${configs.gap / 1.8 + 1.8}px;
    padding: ${styleCalc().danmuItemPaddingTop}px ${configs.gap * 1.5}px;
    margin: ${styleCalc().danmuItemMargin}px;
    display: ${danmuItemDisplay};
    ${danmuItemLineHeight};
  }
  .danmu-item img {
    border-radius: 888px;
    width: auto;
    height: ${configs.fontSize * 1.18}px;
    margin-right: ${configs.fontSize / 3}px;
    display: inline;
    vertical-align: middle;
  }
  .danmu-item .first-td {
    vertical-align: super;
    width: 1.28em;
    min-width: 1.28em;
  }
  .danmu-item a {
    color: lightblue;
  }
  .danmu-text {
    color: white;
  }`;
  let showModeStyle = `#danmu-content { display: block; }`;
  switch (text['中文'].modes[configs.showMode]) {
    case '全隐藏':
      showModeStyle = `#danmu-content { display: none; }`;
      break;
    case '全显示':
      showModeStyle += `
        .danmu-username-long { display: inline !important; }
        .danmu-username-short { display: none !important; }`;
      break;
    case '短用户名':
      showModeStyle += `
        .danmu-username-long { display: none !important; }
        .danmu-username-short {
          display: inline-block !important;
          min-width: 2em;
        }`;
      break;
    case '无用户名':
      showModeStyle += `
        .danmu-username-long { display: none !important; }
        .danmu-username-short { display: none !important; }`;
      break;
  };
  floatDanmuStyle.textContent = baseStyle + showModeStyle;
};

// ⬜️ 文字 + 样式初始化
const danmuHTML = `
<div id="danmu-ctrl" >
  <button id="danmu-settings"></button>&nbsp;
  <button id="danmu-show-mode"></button>&nbsp;
  <button id="danmu-language"></button>&nbsp;
</div>
<div id="danmu-content"></div>
<div id="danmu-pop-board">
<div id="danmu-pop-board-in">
  <div style="display: flex; gap: 0.5em">
    <div class="small-setting">
      <label for="danmu-single-line">
        <input type="checkbox" id="danmu-single-line">abc
      </label>
    </div>
    <div class="small-setting">
      <span id="danmu-fontsize"></span>
      <button id="danmu-fontsize-add">+</button>
      <button id="danmu-fontsize-minus">-</button>
    </div>
    <div class="small-setting">
      <span id="danmu-speed"></span>
      <button id="danmu-speed-add">+</button>
      <button id="danmu-speed-minus">-</button>
    </div>
    <div class="small-setting">
      <span id="danmu-gap"></span>
      <button id="danmu-gap-add">+</button>
      <button id="danmu-gap-minus">-</button>
    </div>
    <div class="small-setting">
      <span id="danmu-transparent"></span>
      <button id="danmu-transparent-add">+</button>
      <button id="danmu-transparent-minus">-</button>
    </div>
    <div class="small-setting">
      <span id="danmu-height"></span>
      <button id="danmu-height-add">+</button>
      <button id="danmu-height-minus">-</button>
    </div>
  </div>
  <div style="margin: 0.8em 0; background-color: #fafafa; padding: 0.5em;
    border-radius: 0.5em; width: fit-content">
    <label for="danmu-twitch-active-check">
      <input type="checkbox" id="danmu-twitch-active-check">
      <span id="danmu-twitch-tip"></span>&nbsp;
    </label>
    <input type="text" id="danmu-twitch-link">&nbsp;
  </div>
  <div id="danmu-name-container">
    <div id="danmu-name-tip" style="line-height: 1.58em;
      overflow-y: auto; word-wrap: break-word; "></div>
    <div>
      <label for="danmu-is-focus-names">
        <input type="checkbox" id="danmu-is-focus-names">
      </label>
      <textarea id="danmu-focus-names"></textarea>
    </div>
    <div>
      <label for="danmu-is-highlight-names">
        <input type="checkbox" id="danmu-is-highlight-names">
      </label>
      <textarea id="danmu-highlight-names"></textarea>
    </div>
    <div>
      <label for="danmu-is-block-names">
        <input type="checkbox" id="danmu-is-block-names">
      </label>
      <textarea id="danmu-block-names"></textarea>
    </div>
  </div>
  <div style="display: inline-block; text-align: center;width: 100%;">
    <button id="danmu-pop-board-submit" style"display: inline-block; margin: 0 5px"></button>
  </div>
</div>
</div>
<iframe style="display: none;"></iframe>
`;

function eleRefresh(danmuEle) {
  danmuEle = danmuEle || videoDoc.querySelector('#danmu-ele');
  if (!danmuEle) return;
  danmuEle.querySelector('#danmu-settings').innerText = text[configs.language].settings;
  danmuEle.querySelector('#danmu-show-mode').innerText = text[configs.language].modes[configs.showMode];
  danmuEle.querySelector('#danmu-language').innerText = text[configs.language].nextLanguage;
  danmuEle.querySelector('#danmu-single-line').checked = configs.singleLine;
  danmuEle.querySelector('#danmu-single-line').nextSibling.textContent =
    `${text[configs.language].singleLine}`;
  danmuEle.querySelector('#danmu-fontsize').innerText = `${text[configs.language].fontSize} ${configs.fontSize}`;
  danmuEle.querySelector('#danmu-speed').innerText = `${text[configs.language].speed} ${configs.speed.toFixed(2)}`;
  danmuEle.querySelector('#danmu-gap').innerText = `${text[configs.language].gap} ${configs.gap}`;
  danmuEle.querySelector('#danmu-transparent').innerText =
    `${text[configs.language].transparency} ${configs.transparent.toFixed(2)}`;
  danmuEle.querySelector('#danmu-height').innerText = `${text[configs.language].height} ${configs.maxHeight}`;
  danmuEle.querySelector('#danmu-twitch-tip').innerText = text[configs.language].twichTip;
  danmuEle.querySelector('#danmu-twitch-link').placeholder = text[configs.language].twitchLinkPlaceholder;
  /* 文字更新的时间点:面板弹出 */
  danmuEle.querySelector('#danmu-twitch-active-check').checked = configs.isTwitchActive;
  if (configs.isTwitchActive && !danmuEle.querySelector('iframe').src && configs.twitchLink) {
    let embedLink = twitchLinkEmbed(configs.twitchLink);
    if (embedLink) danmuEle.querySelector('iframe').src = embedLink;
  };
  danmuEle.querySelector('#danmu-is-focus-names').checked = configs.isFocusNames;
  danmuEle.querySelector('#danmu-is-focus-names').nextSibling.textContent = `${text[configs.language].focusMode}`;
  danmuEle.querySelector('#danmu-is-highlight-names').checked = configs.isHighlightNames;
  danmuEle.querySelector('#danmu-is-highlight-names').nextSibling.textContent =
    `${text[configs.language].highlightMode}`;
  danmuEle.querySelector('#danmu-is-block-names').checked = configs.isBlockNames;
  danmuEle.querySelector('#danmu-is-block-names').nextSibling.textContent = `${text[configs.language].blockMode}`;
  danmuEle.querySelector('#danmu-name-tip').innerHTML = `${text[configs.language].nameTip}`;
  danmuEle.querySelector('#danmu-pop-board-submit').innerText = `${text[configs.language].popBoardConfirm}`;
  setStyle();
  let codeEles = danmuEle.querySelectorAll('code');
  codeEles.forEach(el => {
    el.addEventListener('click', e => {
      navigator.clipboard.writeText(el.innerText);
      alert(text[configs.language].cpoiedTip);
    });
  });
};

// ⬜️⬜️ 建立基本元素
function getDanmuEle() {
  let danmuEle = document.createElement('div')
  danmuEle.id = 'danmu-ele';
  const policy = window.trustedTypes.createPolicy('default', {
    createHTML: a => a
  })
  danmuEle.innerHTML = policy.createHTML(danmuHTML);
  eleRefresh(danmuEle);
  let danmuContentEl = danmuEle.querySelector('#danmu-content');

  // ⬜️ 油猴脚本按钮初始化
  let menuIndex = {};
  function resetEl(danmuEle) {
    eleRefresh(danmuEle);
    danmuEle.style.top = '';
    danmuEle.style.left = '';
    danmuEle.querySelector('#danmu-ctrl').style.visibility = 'visible';
    danmuEle.querySelector('#danmu-content').style.height = defaultPosition.maxHeight + 'px';
    danmuEle.querySelector('#danmu-content').style.maxHeight = defaultPosition.maxHeight + 'px';
  }
  const menuFuncs = {
    menuSetting: settingsPopout,
    menuResetPosition: () => {
      setLocal(defaultPosition);
      resetEl(danmuEle);
    },
    menuResetExceptNames: () => {
      let oldConfigs = deepCopy(configs);
      localStorage.removeItem('danmuConfigs');
      getLocal();
      setLocal({
        focusNames: oldConfigs.focusNames,
        highlightNames: oldConfigs.highlightNames,
        blockNames: oldConfigs.blockNames
      });
      resetEl(danmuEle);
    },
    menuResetAll: () => {
      if (confirm(text[configs.language].menuResetAllConfirm)) {
        setLocal(defaultConfigs);
        getLocal();
        setLocal();
        resetEl(danmuEle);
      } else return;
    },
  };
  menuRefresh();
  function menuRefresh() {
    for (let i in menuIndex) GM_unregisterMenuCommand(menuIndex[i]);
    for (let i in menuFuncs) menuIndex[i] = GM_registerMenuCommand(text[configs.language][i], menuFuncs[i]);
  };

  // ⬜️⬜️ 阻断点击事件穿透 #屏蔽
  danmuEle.querySelector('#danmu-ctrl').addEventListener('click', event => event.stopPropagation());
  danmuEle.querySelector('#danmu-ctrl').addEventListener('dblclick', event => event.stopPropagation());

  // ⬜️ 设置按钮 面板弹出
  function settingsPopout() {
    if (danmuEle.querySelector('#danmu-pop-board').style.display == 'block') {
      settingSubmit();
    } else { // 标记:设置中的所有文字更新都要看看这里
      danmuEle.querySelector('#danmu-twitch-link').value = configs.twitchLink;
      danmuEle.querySelector('#danmu-focus-names').value = configs.focusNames.join('\n');
      danmuEle.querySelector('#danmu-highlight-names').value = configs.highlightNames.join('\n');
      danmuEle.querySelector('#danmu-block-names').value = configs.blockNames.join('\n');
      eleRefresh(danmuEle);
      danmuEle.querySelector('#danmu-pop-board').style.display = 'block';
      videoDoc.querySelector('#masthead-container').style.display = 'none'; // 避免 YouTube 遮挡
    };
  }
  danmuEle.querySelector('#danmu-settings').addEventListener('click', settingsPopout);

  // ⬜️ 显示模式切换 全显示 长短名字 全隐藏
  danmuEle.querySelector('#danmu-show-mode').addEventListener('click', () => {
    setLocal({ showMode: (configs.showMode + 1) % text[configs.language].modes.length });
    danmuEle.querySelector('#danmu-show-mode').innerText = text[configs.language].modes[configs.showMode];
    checkHeight(danmuEle);
    setStyle();
  });

  // ⬜️ 语言切换
  danmuEle.querySelector('#danmu-language').addEventListener('click', () => {
    setLocal({ language: text[configs.language].nextLanguage });
    eleRefresh(danmuEle);
    menuRefresh();
  });

  // ⬜️ 行显示模式 单列多列
  danmuEle.querySelector('#danmu-single-line').addEventListener('change', event => {
    setLocal({ singleLine: event.target.checked });
    setStyle();
    checkHeight(danmuEle);
  });

  // ⬜️ 控制功能 - 字号大小
  function fontSizeChange(change) {
    setLocal({ fontSize: Math.max(0, configs.fontSize + change) });
    danmuEle.querySelector('#danmu-fontsize').innerText = `${text[configs.language].fontSize} ${configs.fontSize}`;
    setStyle();
  };
  danmuEle.querySelector('#danmu-fontsize-add').addEventListener('click', e => fontSizeChange(1));
  danmuEle.querySelector('#danmu-fontsize-minus').addEventListener('click', e => fontSizeChange(-1));

  // ⬜️ 控制功能 - 速度
  function speedChange(change) {
    setLocal({ speed: Math.max(0, Number((configs.speed + change).toFixed(2))) });
    danmuEle.querySelector('#danmu-speed').innerText = `${text[configs.language].speed} ${configs.speed.toFixed(2)}`;
    setStyle();
  };
  danmuEle.querySelector('#danmu-speed-add').addEventListener('click', e => speedChange(0.05));
  danmuEle.querySelector('#danmu-speed-minus').addEventListener('click', e => speedChange(-0.05));

  // ⬜️ 控制功能 - 间距大小
  function gapChange(change) {
    setLocal({ gap: configs.gap + change });
    danmuEle.querySelector('#danmu-gap').innerText = `${text[configs.language].gap} ${configs.gap}`;
    setStyle();
  };
  danmuEle.querySelector('#danmu-gap-add').addEventListener('click', e => gapChange(1));
  danmuEle.querySelector('#danmu-gap-minus').addEventListener('click', e => gapChange(-1));

  // ⬜️ 控制功能 - 透明度
  let transparentTimerI;
  function transparentChange(change) {
    change = Number((configs.transparent + change).toFixed(2));
    change = Math.max(0, change);
    change = Math.min(1, change);
    setLocal({ transparent: change });
    danmuEle.querySelector('#danmu-transparent').innerText =
      `${text[configs.language].transparency} ${configs.transparent.toFixed(2)}`;
    setStyle();
  };
  function transparentMouseDown(change) {
    transparentChange(change);
    transparentTimerI = setInterval(() => {
      transparentChange(change * 8);
    }, 888)
    videoDoc.addEventListener('mouseup', transparentMouseStop);
  };
  function transparentMouseStop() {
    clearInterval(transparentTimerI);
    videoDoc.removeEventListener('mouseup', transparentMouseStop);
  }
  danmuEle.querySelector('#danmu-transparent-add')
    .addEventListener('mousedown', e => transparentMouseDown(0.01));
  danmuEle.querySelector('#danmu-transparent-minus')
    .addEventListener('mousedown', e => transparentMouseDown(-0.01));

  // ⬜️ 控制功能 - 高度
  function setHeight(num) {
    setLocal({ maxHeight: Math.max(0, configs.maxHeight + num) });
    danmuContentEl.style.height = `${configs.maxHeight - 1}px`;
    danmuContentEl.style.maxHeight = `${configs.maxHeight}px`;
    danmuEle.querySelector('#danmu-height').innerText = `${text[configs.language].height} ${configs.maxHeight}`;
    setStyle();
  }
  danmuEle.querySelector('#danmu-height-add').addEventListener('click', e => setHeight(18));
  danmuEle.querySelector('#danmu-height-minus').addEventListener('click', e => setHeight(-18));

  // ⬜️ twitch 链接
  danmuEle.querySelector('#danmu-twitch-link').addEventListener('change', event => {
    setLocal({ twitchLink: event.target.value });
    if (configs.isTwitchActive) {
      let embedLink = twitchLinkEmbed(event.target.value);
      if (embedLink !== undefined) danmuEle.querySelector('iframe').src = embedLink;
    };
  });
  danmuEle.querySelector('#danmu-twitch-active-check').addEventListener('change', e => {
    setLocal({ isTwitchActive: e.target.checked });
    if (configs.isTwitchActive) {
      let embedLink = twitchLinkEmbed(configs.twitchLink);
      if (embedLink !== undefined) danmuEle.querySelector('iframe').src = embedLink;
    } else danmuEle.querySelector('iframe').src = '';
  });

  // ⬜️ 弹幕过滤设置开关、规则编辑
  danmuEle.querySelector('#danmu-is-focus-names').addEventListener('change', event => {
    setLocal({ isFocusNames: event.target.checked });
  });
  danmuEle.querySelector('#danmu-is-highlight-names').addEventListener('change', event => {
    setLocal({ isHighlightNames: event.target.checked });
  });
  danmuEle.querySelector('#danmu-is-block-names').addEventListener('change', event => {
    setLocal({ isBlockNames: event.target.checked });
  });
  function namesSave(toChange) {
    toChange = toChange ? [].concat(toChange) : ['focus', 'highlight', 'block'];
    toChange.forEach(item => {
      setLocal({
        [`${item}Names`]: danmuEle.querySelector(`#danmu-${item}-names`).value.split('\n').filter(item => item.trim())
      });
    })
  }
  danmuEle.querySelector('#danmu-focus-names').addEventListener('change', e => namesSave('focus'));
  danmuEle.querySelector('#danmu-highlight-names').addEventListener('change', e => namesSave('highlight'));
  danmuEle.querySelector('#danmu-block-names').addEventListener('change', e => namesSave('block'));

  // ⬜️ 面板关闭
  function settingSubmit() {
    danmuEle.querySelector('#danmu-pop-board').style.display = 'none';
    videoDoc.querySelector('#masthead-container').style.display = 'block';
  };
  danmuEle.querySelector('#danmu-pop-board-submit').addEventListener('click', e => settingSubmit());

  // ⬜️ 移入移出显示
  let isMouseIn;
  danmuEle.addEventListener('mouseenter', () => {
    isMouseIn = true;
    danmuEle.querySelector('#danmu-ctrl').style.visibility = 'visible';
    danmuContentEl.style.borderBottom = 'Coral solid 0.1px';
    danmuContentEl.style.borderLeft = '8.8px dashed Coral';
    danmuContentEl.style.borderRight = '8.8px dashed Coral';
    danmuContentEl.style.height = `${configs.maxHeight - 1}px`;
  });
  danmuEle.addEventListener('mouseleave', () => {
    isMouseIn = false;
    setTimeout(() => {
      if (!isMouseIn) {
        danmuEle.querySelector('#danmu-ctrl').style.visibility = 'hidden';
        danmuContentEl.style.borderBottom = '';
        danmuContentEl.style.borderLeft = '';
        danmuContentEl.style.borderLeft = '';
        danmuContentEl.style.border = '';
        danmuContentEl.style.height = 'auto';
      }
    }, 158)
  });

  // ⬜️ 鼠标边缘箭头
  let mouseStatus = { width: 0, height: 0, left: 0 };
  const cursorStyles = ['n-resize', 'ne-resize', 'e-resize', 'se-resize',
    's-resize', 'sw-resize', 'w-resize', 'nw-resize'];
  let cursorIndex = { index: 0, time: Date.now() };
  function changeCursor() {
    let time = Date.now();
    if (time - cursorIndex.time > 88) {
      cursorIndex.time = time;
      danmuContentEl.style.cursor = cursorStyles[(++cursorIndex.index) % cursorStyles.length];
    } else danmuContentEl.style.cursor = cursorStyles[cursorIndex.index % cursorStyles.length];
  }
  danmuContentEl.addEventListener('mousemove', function (event) {
    const rect = danmuContentEl.getBoundingClientRect();
    const offset = 18;
    if (event.clientX <= rect.right && event.clientX >= rect.right - offset &&
      event.clientY <= rect.bottom && event.clientY >= rect.bottom - offset) {
      // danmuContentEl.style.cursor = 'nwse-resize'; // 右下
      // mouseStatus = { width: 1, height: 1, left: 0 };
    } else if (event.clientX >= rect.left && event.clientX <= rect.left + offset &&
      event.clientY <= rect.bottom && event.clientY >= rect.bottom - offset) {
      // danmuContentEl.style.cursor = 'nesw-resize'; // 左下
      // mouseStatus = { width: -1, height: 1, left: 1 };
    } else if (event.clientX >= rect.left && event.clientX <= rect.left + offset) {
      // danmuContentEl.style.cursor = 'ew-resize'; // 左
      // mouseStatus = { width: -1, height: 0, left: 1 };
      // danmuContentEl.style.cursor = 'all-scroll';
      changeCursor();
      mouseStatus = { width: -1, height: 1, left: 1 };
    } else if (event.clientX <= rect.right && event.clientX >= rect.right - offset) {
      // danmuContentEl.style.cursor = 'ew-resize'; // 右
      // mouseStatus = { width: 1, height: 0, left: 0 };
      // danmuContentEl.style.cursor = 'all-scroll';
      changeCursor();
      mouseStatus = { width: 1, height: 1, left: 0 };
    } else if (event.clientY <= rect.bottom && event.clientY >= rect.bottom - offset) {
      // danmuContentEl.style.cursor = 'ns-resize'; // 下
      // mouseStatus = { width: 0, height: 1, left: 0 };
    } else {
      danmuContentEl.style.cursor = 'default'; // 默认箭头
      mouseStatus = { width: 0, height: 0, left: 0 };
    }
  });

  // ⬜️ 边缘拖拽
  danmuContentEl.addEventListener('mousedown', function (event) {
    event.stopPropagation();
    let doc = event.target.ownerDocument;
    let x = event.clientX;
    let y = event.clientY;
    let width = danmuEle.offsetWidth;
    let height = danmuContentEl.offsetHeight;
    let left = danmuEle.offsetLeft;
    let mouse = deepCopy(mouseStatus); // 以免在移动中变化

    function doDrag(e) {
      e.stopPropagation();
      danmuEle.style.width = width + mouse.width * (e.clientX - x) + 'px';
      danmuContentEl.style.height = height + mouse.height * (e.clientY - y) + 'px';
      danmuContentEl.style.maxHeight = height + mouse.height * (e.clientY - y) + 'px';
      danmuEle.style.left = left + mouse.left * (e.clientX - x) + 'px';
    };

    function stopDrag(e) {
      mouseStatus = { width: 0, height: 0, left: 0 };
      e.stopPropagation();
      videoDoc.body.style.userSelect = '';
      videoDoc.body.style.webkitUserSelect = '';
      videoDoc.body.style.msUserSelect = '';
      videoDoc.body.style.mozUserSelect = '';
      setLocal({
        width: danmuContentEl.offsetWidth,
        maxHeight: danmuContentEl.offsetHeight,
        left: danmuEle.offsetLeft
      });
      eleRefresh(danmuEle);
      checkHeight(danmuEle);
      doc.removeEventListener('mousemove', doDrag);
      doc.removeEventListener('mouseup', stopDrag);
    };

    if (mouseStatus.width || mouseStatus.height) {
      videoDoc.body.style.userSelect = 'none';
      videoDoc.body.style.webkitUserSelect = 'none';
      videoDoc.body.style.msUserSelect = 'none';
      videoDoc.body.style.mozUserSelect = 'none';
      doc.addEventListener('mousemove', doDrag);
      doc.addEventListener('mouseup', stopDrag);
    };
  });

  // ⬜️ 整体拖拽
  danmuEle.querySelector('#danmu-ctrl').style.cursor = 'grab';
  danmuEle.querySelector('#danmu-ctrl').addEventListener('mousedown', drag);
  function drag(e) {
    let doc = e.target.ownerDocument;
    e.stopPropagation();
    e.preventDefault();
    let shiftX = e.clientX - danmuEle.getBoundingClientRect().left;
    let shiftY = e.clientY - danmuEle.getBoundingClientRect().top;
    function moveAt(pageX, pageY) {
      danmuEle.querySelector('#danmu-ctrl').style.visibility = 'visible';
      configs.top = pageY - shiftY;
      configs.left = pageX - shiftX;
      danmuEle.style.top = configs.top + 'px';
      danmuEle.style.left = configs.left + 'px';
    }
    function onMouseMove(event) {
      moveAt(event.pageX, event.pageY);
    }
    doc.addEventListener('mousemove', onMouseMove);
    doc.addEventListener('mouseup', function () {
      setLocal();
      doc.removeEventListener('mousemove', onMouseMove);
      doc.onmouseup = null;
    }, { once: true });
  }
  return danmuEle;
};

// ⬜️ 蜂鸣器,调试用
function beep() {
  const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  const oscillator = audioCtx.createOscillator();
  oscillator.type = 'sine';
  oscillator.frequency.setValueAtTime(440, audioCtx.currentTime); // A4音
  oscillator.connect(audioCtx.destination);
  oscillator.start();
  setTimeout(() => { oscillator.stop() }, 1000);
}

// console. log('YouTube 悬浮弹幕');
// 边缘测试:
//   iframe重新加载时,会不会清空
//   从直播跳到视频时,会不会清空
// greasyfork: https://greasyfork.org/en/scripts/500209-
// 代码 https://github.com/67373net/youtube-float-danmu/blob/main/index.js
// 测试地址
//   弹幕慢:https://www.youtube.com/live/5FUWAwWJrkQ?t=3341s
//   弹幕快:https://www.youtube.com/live/m8nButUrSYk?si=6ezF7VgSTtEKeoQl&t=6452
//   直播中:https://www.youtube.com/watch?v=jfKfPfyJRdk
//   带链接:https://www.youtube.com/live/hs6WyhTWrRE?si=lxRnec0kyNO0EHFt&t=2097