YouTube Live: Highlight Moderator Comments

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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);
  }
})();