Greasy Fork is available in English.

YouTube Live: Highlight Moderator Comments

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==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);
  }
})();