YouTube LiveChat Enhancer

enhance livechat on youtube

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

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

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         YouTube LiveChat Enhancer
// @namespace    http://james0x57.com/
// @version      0.2
// @description  enhance livechat on youtube
// @author       James0x57
// @match        https://www.youtube.com/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
  'use strict';

  //## Begin Code I didn't write
    // credit: https://stackoverflow.com/a/6969486/1527109
    function escapeRegExp(str) {
      return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
    }
  //## End Code I didn't write

  var fcs = function(fn) { //functionalCommentString
    /*Function By James Atherton - http://geckocodes.org/?hacker=James0x57 */
    /*You are free to copy and alter however you'd like so long as you leave the credit intact! =)*/
    return fn.toString().replace(/^(\r?\n|[^\/])*\/\*!?(\r?\n)*|\s*\*\/(\r|\n|.)*$/gi,"");
  };

  function addCSS(css) {
    var el = document.createElement('div');
    el.innerHTML = '<b>CSS</b><style type="text/css">' + css + '</style>';
    el = el.childNodes[1];
    if (el) document.getElementsByTagName('head')[0].appendChild(el);
    return el;
  }

  // https://gist.github.com/James0x57/da84cc2bb6087db5f041387b0a586e6c
  //## Begin Selector Observation Code
    var selectors = [];
    (new MutationObserver(
      function (mutationsList) {
        var s, selector, nodeMatches
        var slen = selectors.length
        for (s = 0; s < slen; s++) {
          selector = selectors[s]
          nodeMatches = node => node.nodeType === 1 && node.matches(selector.childSelector)

          mutationsList.forEach(mu => {
            if (mu.type === "childList" && mu.target.matches(selector.parentSelector)) {
              var addedMatches = Array.prototype.filter.call(mu.addedNodes, nodeMatches)
              var removedMatches = Array.prototype.filter.call(mu.removedNodes, nodeMatches)
              addedMatches.length && selector.inserted.call(null, addedMatches)
              removedMatches.length && selector.removed.call(null, removedMatches)
            }
          })
        }
      }
    )).observe(document.documentElement, {
      childList: true,
      subtree: true
    })

    // watch the parentSelector for the specific children to be added or removed,
    // call inserted as that parentSelector > children are added and they match childSelector
    // call removed as that parentSelector > children are removed and they match childSelector (note they won't be in the dom any more)
    // inserted and removed callbacks are called with the matching elements passed in as the only parameter (is an array)
    var onParentChildSelectors = function (opts) {
      var nullFn = () => {}
      selectors.push(Object.assign({
        parentSelector: "",
        childSelector: "",
        inserted: nullFn,
        removed: nullFn
      }, opts))
    }

    // remove all watch selectors:
    // offSelector({ parentSelector }) -> matching that selector
    // offSelector({ parentSelector, childSelector }) -> matching that selector
    // offSelector({ parentSelector, inserted }) -> matching that parentSelector && matching that inserted function
    // offSelector({ parentSelector, childSelector, inserted }) -> matching that selector && matching that inserted function
    // offSelector({ parentSelector, childSelector, removed }) -> matching that selector && matching that removed function
    // offSelector({ parentSelector, childSelector, inserted, removed }) -> matching that selector, inserted function, and removed function
    var offSelector = function (opts) {
      for (let s = 0; s < selectors.length; s++) {
        let selectorObj = selectors[s]
        let comp = Object.assign({}, selectorObj, opts)
        let matchingProps = ["parentSelector", "childSelector", "inserted", "removed"].filter(prop => selectorObj[prop] === comp[prop])
        if (matchingProps.length === 4) {
          selectors.splice(s, 1)
          s--
        }
      }
    }
  //## End Selector Observation Code

  addCSS(fcs(function() {/*!
    @keyframes jca_highlight {
      0% {
        background: #88ccff;
      }
      100% {
        background: transparent;
      }
    }

    .jca-jump-highlight {
      animation: jca_highlight 2s;
    }

    .jca-user-ref {
      background-color: rgba(128, 128, 128, 0.15);
      cursor: pointer;
    }
    .jca-user-ref:before {
      content: "@";
    }
  */}))

  var userInfo = {}

  var userRefClickFn = function () {
    var userRefSpan = this
    var referencedMessageEl = document.querySelector('yt-live-chat-renderer #chat #items #' + userRefSpan.getAttribute("data-last-id"))
    if (referencedMessageEl) {
      referencedMessageEl.scrollIntoView({
        behavior: "smooth",
        block: "nearest",
        inline: "center"
      })
      referencedMessageEl.classList.add("jca-jump-highlight")
      setTimeout(() => referencedMessageEl.classList.remove("jca-jump-highlight"), 1000)
    }
  }

  var createSpanAndReferenceUsers = function (text) {
    var span = document.createElement("span")
    // sort with longer names first because if somebody's name is a subset of another user's. eg "Amber S" and "Amber" then we'll get the right one
    var users = Object.keys(userInfo).sort((a, b) => b.length - a.length || a.localeCompare(b));
    users.forEach(user => {
      let info = userInfo[user]
      text = text.replace(info.userNameRx, `<span class="jca-user-ref" data-last-id="${info.escapedId}">${user}</span>`)
    })
    span.className = "jca-text-node-into-span"
    span.innerHTML = text
    span.querySelectorAll(".jca-user-ref").forEach(function (userSpan) {
      userSpan.addEventListener("click", userRefClickFn.bind(userSpan), false)
    })
    return span
  }

  var decorateMessage = function (messageEl) {
    // console.log(messageEl.textContent)
    var messageParts = messageEl.childNodes || []

    messageParts.forEach(node => {
      if (node.nodeType === 3) { // textNode
        let text = node.textContent
        let hasUserRef = /@/.test(text)
        if (hasUserRef) {
          let span = createSpanAndReferenceUsers(text)
          messageEl.replaceChild(span, node)
        }
      }
    })
  }

  var handleNewChat = function (chatEl) {
    var escapedId = chatEl.id.replace(/%/g, "\\%")
    var user = chatEl.querySelector("#author-name").textContent.trim()
    var messageEl = chatEl.querySelector("#message")
    var message = messageEl && messageEl.textContent.trim()
    userInfo[user] = {
      user,
      userNameRx: new RegExp("@" + escapeRegExp(user) + "\\b", "gi"),
      lastId: chatEl.id,
      escapedId,
      lastMessage: message
    }
    messageEl && decorateMessage(messageEl)
  }

  onParentChildSelectors({
    parentSelector: "yt-live-chat-renderer #chat #items",
    childSelector: "yt-live-chat-text-message-renderer, yt-live-chat-paid-message-renderer",
    inserted: addedChatEls => addedChatEls.forEach(handleNewChat)
    // removed
  })
})()