YouTube Live: Highlight Moderator Comments

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

  1. // ==UserScript==
  2. // @name YouTube Live: Highlight Moderator Comments
  3. // @namespace https://twitter.com/aryn_ra
  4. // @version 1.0.1
  5. // @description YouTube Live のチャットでチャンネルの所有者・モデレータの発言を目立たせ、自動で上部に固定する
  6. // @author Aryn
  7. // @match https://www.youtube.com/live_chat?*
  8. // @match https://www.youtube.com/live_chat_replay?*
  9. // @grant GM_addStyle
  10. // @grant GM_getValue
  11. // @grant GM_setValue
  12. // ==/UserScript==
  13.  
  14. /* jshint esversion: 6 */
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. const HIGHLIGHT_BACKGROUND_COLOR_LIGHT = 'lavender';
  20. const HIGHLIGHT_BACKGROUND_COLOR_DARK = 'black';
  21. const PIN_LIMIT = 5;
  22. const BANNER_OFFSET = 66;
  23. const css = `
  24. html {
  25. /* light theme */
  26. --highlight-background-color: ${HIGHLIGHT_BACKGROUND_COLOR_LIGHT};
  27. }
  28.  
  29. html[dark] {
  30. /* dark theme */
  31. --highlight-background-color: ${HIGHLIGHT_BACKGROUND_COLOR_DARK};
  32. }
  33.  
  34. #item-offset {
  35. overflow: visible !important;
  36. }
  37.  
  38. #items {
  39. transform: none !important;
  40. }
  41.  
  42. yt-live-chat-text-message-renderer[author-type=owner].hmc-highlight,
  43. yt-live-chat-text-message-renderer[author-type=moderator].hmc-highlight {
  44. position: sticky;
  45. z-index: 1;
  46. transition: top ease-in-out .1s;
  47. background-color: var(--highlight-background-color);
  48. }
  49.  
  50. #live-chat-banner {
  51. z-index: 2;
  52. }
  53.  
  54. #hmc-config {
  55. position: absolute;
  56. right: 84px;
  57. width: 24px;
  58. height: 24px;
  59. margin: 8px;
  60. cursor: pointer;
  61. user-select: none;
  62. opacity: .8;
  63. }
  64.  
  65. #hmc-config:hover {
  66. opacity: 1;
  67. }
  68.  
  69. #hmc-config-popover {
  70. position: absolute;
  71. top: 48px;
  72. left: 0;
  73. display: none;
  74. width: 400px;
  75. height: 400px;
  76. padding: 8px;
  77. color: var(--yt-spec-text-primary);
  78. background-color: var(--yt-spec-general-background-b);
  79. }
  80.  
  81. #hmc-config-popover.hmc-show {
  82. display: block;
  83. }
  84.  
  85. #hmc-config-popover h1 {
  86. font-size: 18px;
  87. font-weight: normal;
  88. line-height: 18px;
  89. margin-bottom: 8px;
  90. }
  91.  
  92. #hmc-config-popover p {
  93. font-size: 12px;
  94. line-height: 12px;
  95. width: 384px;
  96. }
  97.  
  98. #hmc-config-popover textarea {
  99. font-size: 15px;
  100. box-sizing: border-box;
  101. width: 384px;
  102. height: 322px;
  103. margin: 8px 0;
  104. color: var(--ytd-searchbox-text-color);
  105. border: 1px solid var(--ytd-searchbox-legacy-border-color);
  106. background-color: var(--ytd-searchbox-background);
  107. }
  108.  
  109. #hmc-config-popover input[type='button'],
  110. #hmc-config-popover input[type='reset'] {
  111. font-size: 12px;
  112. box-sizing: border-box;
  113. height: 24px;
  114. }
  115. `;
  116.  
  117. const exclusionList = GM_getValue('exclusion-list', []);
  118. const itemsObserver = new MutationObserver((mutations) => {
  119. mutations.forEach((mutation) => {
  120. function isModerator(node) {
  121. const authorType = node.getAttribute('author-type');
  122. return authorType === 'owner' || authorType === 'moderator';
  123. }
  124.  
  125. function isExcluded(node) {
  126. const channelName = node.querySelector('#author-name').textContent.trim();
  127. return exclusionList.includes(channelName)
  128. }
  129.  
  130. function isHighlightTarget(node) {
  131. return isModerator(node) && !isExcluded(node);
  132. }
  133.  
  134. const nodes = [...mutation.target.children].filter(isHighlightTarget);
  135. for (const node of nodes) {
  136. node.classList.add('hmc-highlight');
  137. }
  138. const unpinNodes = nodes.slice(0, -PIN_LIMIT);
  139. for (const node of unpinNodes) {
  140. node.style.transition = '';
  141. node.style.top = '';
  142. }
  143. const pinNodes = nodes.slice(-PIN_LIMIT);
  144. const existsActiveBanner = document.getElementById('live-chat-banner').hasAttribute('has-active-banner');
  145. let offset = existsActiveBanner ? BANNER_OFFSET : 0;
  146. for (const node of pinNodes) {
  147. node.style.top = `${offset}px`;
  148. offset += node.clientHeight;
  149. }
  150. });
  151. });
  152.  
  153. const items = document.querySelector('#item-offset #items');
  154. itemsObserver.observe(items, {
  155. childList: true
  156. });
  157.  
  158. const itemListObserver = new MutationObserver(() => {
  159. const items = document.querySelector('#item-offset #items');
  160. itemsObserver.observe(items, {
  161. childList: true
  162. });
  163. });
  164.  
  165. const itemList = document.querySelector('#item-list');
  166. itemListObserver.observe(itemList, {
  167. childList: true
  168. });
  169.  
  170. const configButton = document.createElement('div');
  171. configButton.id = 'hmc-config';
  172. configButton.textContent = '🔧';
  173. configButton.addEventListener('click', () => {
  174. document.querySelector('#hmc-config-popover').classList.toggle('hmc-show');
  175. });
  176. const chatHeader = document.querySelector('yt-live-chat-header-renderer');
  177. chatHeader.appendChild(configButton);
  178. const configPopover = document.createElement('div');
  179. configPopover.id = 'hmc-config-popover';
  180. configPopover.innerHTML = `
  181. <h1>強調除外設定</h1>
  182. <p>1行に1つずつ、除外したいチャンネル名 (表示ユーザー名) を入力</p>
  183. <form name="exclusion-config">
  184. <div><textarea id="hmc-exclusion-text">${exclusionList.join('\n')}</textarea></div>
  185. <div><input type="reset" value="キャンセル" id="hmc-cancel" /> <input type="button" value="保存" id="hmc-save" /></div>
  186. </form>
  187. `;
  188. document.body.appendChild(configPopover);
  189.  
  190. const save = document.querySelector('#hmc-save');
  191. save.addEventListener('click', () => {
  192. const exclusionText = document.querySelector('#hmc-exclusion-text');
  193. GM_setValue('exclusion-list', exclusionText.value.trim().split('\n').map((s) => s.trim()));
  194. location.reload();
  195. });
  196.  
  197. const cancel = document.querySelector('#hmc-cancel');
  198. cancel.addEventListener('click', () => {
  199. document.querySelector('#hmc-config-popover').classList.toggle('hmc-show');
  200. });
  201.  
  202. if (typeof GM_addStyle !== 'undefined') {
  203. GM_addStyle(css);
  204. } else {
  205. const style = document.createElement('style');
  206. style.textContent = css;
  207. document.head.appendChild(style);
  208. }
  209. })();