YouTube Live: Highlight Moderator Comments

YouTube Live のチャットでチャンネルの所有者・モデレータの発言を目立たせ、自動で上部に固定する

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube Live: Highlight Moderator Comments
// @namespace    https://twitter.com/aryn_ra
// @version      1.0.1
// @description  YouTube Live のチャットでチャンネルの所有者・モデレータの発言を目立たせ、自動で上部に固定する
// @author       Aryn
// @match        https://www.youtube.com/live_chat?*
// @match        https://www.youtube.com/live_chat_replay?*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

/* jshint esversion: 6 */

(function() {
  'use strict';

  const HIGHLIGHT_BACKGROUND_COLOR_LIGHT = 'lavender';
  const HIGHLIGHT_BACKGROUND_COLOR_DARK = 'black';
  const PIN_LIMIT = 5;
  const BANNER_OFFSET = 66;
  const css = `
html {
  /* light theme */
  --highlight-background-color: ${HIGHLIGHT_BACKGROUND_COLOR_LIGHT};
}

html[dark] {
  /* dark theme */
  --highlight-background-color: ${HIGHLIGHT_BACKGROUND_COLOR_DARK};
}

#item-offset {
  overflow: visible !important;
}

#items {
  transform: none !important;
}

yt-live-chat-text-message-renderer[author-type=owner].hmc-highlight,
yt-live-chat-text-message-renderer[author-type=moderator].hmc-highlight {
  position: sticky;
  z-index: 1;
  transition: top ease-in-out .1s;
  background-color: var(--highlight-background-color);
}

#live-chat-banner {
  z-index: 2;
}

#hmc-config {
  position: absolute;
  right: 84px;
  width: 24px;
  height: 24px;
  margin: 8px;
  cursor: pointer;
  user-select: none;
  opacity: .8;
}

#hmc-config:hover {
  opacity: 1;
}

#hmc-config-popover {
  position: absolute;
  top: 48px;
  left: 0;
  display: none;
  width: 400px;
  height: 400px;
  padding: 8px;
  color: var(--yt-spec-text-primary);
  background-color: var(--yt-spec-general-background-b);
}

#hmc-config-popover.hmc-show {
  display: block;
}

#hmc-config-popover h1 {
  font-size: 18px;
  font-weight: normal;
  line-height: 18px;
  margin-bottom: 8px;
}

#hmc-config-popover p {
  font-size: 12px;
  line-height: 12px;
  width: 384px;
}

#hmc-config-popover textarea {
  font-size: 15px;
  box-sizing: border-box;
  width: 384px;
  height: 322px;
  margin: 8px 0;
  color: var(--ytd-searchbox-text-color);
  border: 1px solid var(--ytd-searchbox-legacy-border-color);
  background-color: var(--ytd-searchbox-background);
}

#hmc-config-popover input[type='button'],
#hmc-config-popover input[type='reset'] {
  font-size: 12px;
  box-sizing: border-box;
  height: 24px;
}
`;

  const exclusionList = GM_getValue('exclusion-list', []);
  const itemsObserver = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
      function isModerator(node) {
        const authorType = node.getAttribute('author-type');
        return authorType === 'owner' || authorType === 'moderator';
      }

      function isExcluded(node) {
        const channelName = node.querySelector('#author-name').textContent.trim();
        return exclusionList.includes(channelName)
      }

      function isHighlightTarget(node) {
        return isModerator(node) && !isExcluded(node);
      }

      const nodes = [...mutation.target.children].filter(isHighlightTarget);
      for (const node of nodes) {
        node.classList.add('hmc-highlight');
      }
      const unpinNodes = nodes.slice(0, -PIN_LIMIT);
      for (const node of unpinNodes) {
        node.style.transition = '';
        node.style.top = '';
      }
      const pinNodes = nodes.slice(-PIN_LIMIT);
      const existsActiveBanner = document.getElementById('live-chat-banner').hasAttribute('has-active-banner');
      let offset = existsActiveBanner ? BANNER_OFFSET : 0;
      for (const node of pinNodes) {
        node.style.top = `${offset}px`;
        offset += node.clientHeight;
      }
    });
  });

  const items = document.querySelector('#item-offset #items');
  itemsObserver.observe(items, {
    childList: true
  });

  const itemListObserver = new MutationObserver(() => {
    const items = document.querySelector('#item-offset #items');
    itemsObserver.observe(items, {
      childList: true
    });
  });

  const itemList = document.querySelector('#item-list');
  itemListObserver.observe(itemList, {
    childList: true
  });

  const configButton = document.createElement('div');
  configButton.id = 'hmc-config';
  configButton.textContent = '🔧';
  configButton.addEventListener('click', () => {
    document.querySelector('#hmc-config-popover').classList.toggle('hmc-show');
  });
  const chatHeader = document.querySelector('yt-live-chat-header-renderer');
  chatHeader.appendChild(configButton);
  const configPopover = document.createElement('div');
  configPopover.id = 'hmc-config-popover';
  configPopover.innerHTML = `
<h1>強調除外設定</h1>
<p>1行に1つずつ、除外したいチャンネル名 (表示ユーザー名) を入力</p>
<form name="exclusion-config">
  <div><textarea id="hmc-exclusion-text">${exclusionList.join('\n')}</textarea></div>
  <div><input type="reset" value="キャンセル" id="hmc-cancel" /> <input type="button" value="保存" id="hmc-save" /></div>
</form>
`;
  document.body.appendChild(configPopover);

  const save = document.querySelector('#hmc-save');
  save.addEventListener('click', () => {
    const exclusionText = document.querySelector('#hmc-exclusion-text');
    GM_setValue('exclusion-list', exclusionText.value.trim().split('\n').map((s) => s.trim()));
    location.reload();
  });

  const cancel = document.querySelector('#hmc-cancel');
  cancel.addEventListener('click', () => {
    document.querySelector('#hmc-config-popover').classList.toggle('hmc-show');
  });

  if (typeof GM_addStyle !== 'undefined') {
    GM_addStyle(css);
  } else {
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
  }
})();