YouTube 超快聊天

讓您的 YouTube 直播聊天即時滾動,不經過平滑轉換 CSS。

目前為 2023-07-01 提交的版本,檢視 最新版本

// ==UserScript==
// @name                YouTube Super Fast Chat
// @name:ja             YouTube スーパーファーストチャット
// @name:zh-TW          YouTube 超快聊天
// @name:zh-CN          YouTube 超快聊天
// @namespace           UserScript
// @match               https://www.youtube.com/live_chat*
// @version             0.1.2
// @license             MIT
// @author              CY Fung
// @run-at              document-start
// @grant               none
// @unwrap
// @allFrames           true
// @inject-into         page
//
// @description         To make your YouTube Live Chat scroll instantly without smoothing transform CSS
// @description:ja      YouTubeライブチャットをスムーズな変形CSSなしで瞬時にスクロールさせるために。
// @description:zh-TW   讓您的 YouTube 直播聊天即時滾動,不經過平滑轉換 CSS。
// @description:zh-CN   让您的 YouTube 直播聊天即时滚动,不经过平滑转换 CSS。
//
// ==/UserScript==

((__CONTEXT__) => {
  const addCss = () => document.head.appendChild(document.createElement('style')).textContent = `

      @supports (contain: layout paint style) and (content-visibility: auto) and (contain-intrinsic-size: auto var(--wsr94)) {

      [wSr93] {
        content-visibility: visible;
      }

      [wSr93="hidden"]:nth-last-child(n+4) {
        content-visibility: auto;
        contain-intrinsic-size: auto var(--wsr94);
      }

      }

    @supports (contain: layout paint style) {


/* optional */
      #item-offset.style-scope.yt-live-chat-item-list-renderer {
        height: auto !important;
        min-height: unset !important;
      }

      #items.style-scope.yt-live-chat-item-list-renderer {
        transform: translateY(0px) !important;
        /*padding-bottom: 0 !important;
        padding-top: 0 !important;*/
      }

/* optional */

      yt-icon[icon="down_arrow"] > *,
      yt-icon-button#show-more > * {
        pointer-events: none !important;
      }


      #item-list.style-scope.yt-live-chat-renderer,
      yt-live-chat-item-list-renderer.style-scope.yt-live-chat-renderer,
      #item-list.style-scope.yt-live-chat-renderer *,
      yt-live-chat-item-list-renderer.style-scope.yt-live-chat-renderer * {
        will-change: unset !important;
      }

      yt-img-shadow[height][width] {
        content-visibility: visible !important;
      }


      #item-offset.style-scope.yt-live-chat-item-list-renderer > #items.style-scope.yt-live-chat-item-list-renderer {
        position: static !important;
      }


/* ------------------------------------------------------------------------------------------------------------- */

      yt-live-chat-author-chip #chat-badges.yt-live-chat-author-chip,
      yt-live-chat-author-chip #chat-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer,
      yt-live-chat-author-chip #chat-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer #image,
      yt-live-chat-author-chip #chat-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer #image img {
        contain: layout style;
      }

/*
      yt-live-chat-author-chip #chat-badges.yt-live-chat-author-chip,
      yt-live-chat-author-chip #chat-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer,
      yt-live-chat-author-chip #chat-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer #image,
      yt-live-chat-author-chip #chat-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer #image img {
        contain: layout style;
        display: inline-flex;
        vertical-align: middle;
      }
      */

      #items yt-live-chat-text-message-renderer {
        contain: layout style;
      }

      yt-live-chat-item-list-renderer:not([allow-scroll]) #item-scroller.yt-live-chat-item-list-renderer {
        overflow-y: scroll;
        padding-right: 0;
      }

      body yt-live-chat-app {
        contain: size layout paint style;
        overflow: hidden;
      }

      #items.style-scope.yt-live-chat-item-list-renderer {
        contain: layout paint style;
      }

      #item-offset.style-scope.yt-live-chat-item-list-renderer {
        contain: style;
      }

      #item-scroller.style-scope.yt-live-chat-item-list-renderer {
        contain: size style;
      }

      #contents.style-scope.yt-live-chat-item-list-renderer,
      #chat.style-scope.yt-live-chat-renderer,
      img.style-scope.yt-img-shadow[width][height] {
        contain: size layout paint style;
      }

      .style-scope.yt-live-chat-ticker-renderer[role="button"][aria-label],
      .style-scope.yt-live-chat-ticker-renderer[role="button"][aria-label] > #container {
        contain: layout paint style;
      }

      yt-live-chat-text-message-renderer.style-scope.yt-live-chat-item-list-renderer,
      yt-live-chat-membership-item-renderer.style-scope.yt-live-chat-item-list-renderer,
      yt-live-chat-paid-message-renderer.style-scope.yt-live-chat-item-list-renderer,
      yt-live-chat-banner-manager.style-scope.yt-live-chat-item-list-renderer {
        contain: layout style;
      }

      tp-yt-paper-tooltip[style*="inset"][role="tooltip"] {
        contain: layout paint style;
      }

/*
      #item-offset.style-scope.yt-live-chat-item-list-renderer {
        position: relative !important;
        height: auto !important;
      }
*/

/* ------------------------------------------------------------------------------------------------------------- */


      #items.style-scope.yt-live-chat-item-list-renderer {
        padding-top: var(--items-top-padding);
      }


#continuations, #continuations * {
  contain: strict;
  position: fixed;
  top: 2px;
  height: 1px;
  width: 2px;
  height: 1px;
  visibility: collapse;
}


    }

  `;

  const { Promise, requestAnimationFrame } = __CONTEXT__;


  const isContainSupport = CSS.supports('contain', 'layout paint style');
  if (!isContainSupport) {
    console.error(`
YouTube Light Chat Scroll: Your browser does not support 'contain'.
Chrome >= 52; Edge >= 79; Safari >= 15.4, Firefox >= 69; Opera >= 39
`.trim());
    return;
  }

  // const APPLY_delayAppendChild = false;

  let activator = false;

  let mpws = new WeakSet();
  let ops = [];
  let msqs = new Set();

  const sp7 = Symbol();


  let dt0 = Date.now() - 2000;
  const dateNow = () => Date.now() - dt0;
  let lastScroll = 0;
  let lastLShow = 0;

  const phFn = (dummy) => ({

    get(target, prop) {
      return (prop in dummy) ? dummy[prop] : prop === sp7 ? target : target[prop];
    },
    set(target, prop, value) {
      if (!(prop in dummy)) {
        target[prop] = value;
      }
      return true;

    },
    has(target, prop) {
      return (prop in target)
    },
    deleteProperty(target, prop) {

      return true;
    },
    ownKeys(target) {
      return Object.keys(target);
    },
    defineProperty(target, key, descriptor) {
      return Object.defineProperty(target, key, descriptor);
      // return true;
    },
    getOwnPropertyDescriptor(target, key) {
      return Object.getOwnPropertyDescriptor(target, key);
    },



  });


  const dummy3v = {
    "background": "",
    "backgroundAttachment": "",
    "backgroundBlendMode": "",
    "backgroundClip": "",
    "backgroundColor": "",
    "backgroundImage": "",
    "backgroundOrigin": "",
    "backgroundPosition": "",
    "backgroundPositionX": "",
    "backgroundPositionY": "",
    "backgroundRepeat": "",
    "backgroundRepeatX": "",
    "backgroundRepeatY": "",
    "backgroundSize": ""
  };
  for (const k of ['toString', 'getPropertyPriority', 'getPropertyValue', 'item', 'removeProperty', 'setProperty']) {
    dummy3v[k] = ((k) => (function () { const style = this[sp7]; return style[k](...arguments); }))(k)
  }

  // const dummy3p = phFn(dummy3v);

  const pt2DecimalFixer = (x) => Math.round(x * 5, 0) / 5;

  const tickerContainerSetAttribute = function (attrName, attrValue) {

    let yd = (this.__dataHost || 0).__data;

    if (arguments.length === 2 && attrName === 'style' && yd && attrValue) {

      // let v = yd.containerStyle.privateDoNotAccessOrElseSafeStyleWrappedValue_;
      let v = `${attrValue}`;
      // conside a ticker is 101px width
      // 1% = 1.01px
      // 0.2% = 0.202px

      const ratio1 = (yd.ratio * 100);
      const ratio2 = pt2DecimalFixer(ratio1);
      v = v.replace(`${ratio1}%`, `${ratio2}%`).replace(`${ratio1}%`, `${ratio2}%`)

      if (yd.__style_last__ === v) return;
      yd.__style_last__ = v;

      HTMLElement.prototype.setAttribute.call(this, attrName, v);



    } else {
      HTMLElement.prototype.setAttribute.apply(this, arguments);
    }

  };


  /*
   *
   *   const tickerContainerSetAttribute = function (attrName, attrValue) {

    const yd = (this.__dataHost||0).__data;
      if (arguments.length === 2 && attrName === 'style' && attrValue && yd){
          // let v = yd.containerStyle.privateDoNotAccessOrElseSafeStyleWrappedValue_;
          let v = attrValue;

          // conside a ticker is 101px width
          // 1% = 1.01px
          // 0.2% = 0.202px
          const ratio1 = (yd.ratio * 100);
          const ratio2 = pt2DecimalFixer(ratio1);
          v = v.replace(`${ratio1}%`, `${ratio2}%`).replace(`${ratio1}%`, `${ratio2}%`)

        console.log(ratio1, ratio2)
          if (yd.__style_last__ !== v) {
            yd.__style_last__ = v; // clear along with data change

            HTMLElement.prototype.setAttribute.call(this, attrName, v);
            return;
          }


      }
    return HTMLElement.prototype.setAttribute.apply(this, arguments);

        };

        */


  const createRAF = () => requestAnimationFrame(() => {
    const e = [...ops]
    ops.length = 0;
    for (const t of e) t();
  });

  Node.prototype.appendChild = ((appendChild) => (function (s) {
    if (arguments.length !== 1) return appendChild.apply(this, arguments);
    // console.log(34, 1, this.is, this.nodeName, activator, s.nodeName)
    const stack = new Error().stack;

    if (activator && (msqs.has(stack) || s.nodeName === 'YT-LIVE-CHAT-TEXT-MESSAGE-RENDERER') && typeof s.is === 'string') {
      msqs.add(stack);
      // this = '#document-fragment'
      if (this instanceof HTMLElement) {


        if (ops.length === 0) createRAF();
        ops.push(() => {
          appendChild.apply(this, arguments);
        })
        return s;

      } else {

        mpws.add(this);
        appendChild.apply(this, arguments);
        return s;
      }
    } else if (activator && mpws.has(s)) {

      if (this instanceof HTMLElement) {
        if (ops.length === 0) createRAF();
        ops.push(() => {
          mpws.delete(s);
          appendChild.apply(this, arguments);
        })
        return s;
      } else {

        mpws.delete(s);
        appendChild.apply(this, arguments);
        return s;
      }
    } else if (this.nodeName === 'YT-LIVE-CHAT-TICKER-PAID-MESSAGE-ITEM-RENDERER') {



      appendChild.call(this, s);

      let container = this.$.container;
      if (container) {

        // const sp3v = new Proxy(container.style, dummy3p)

        // Object.defineProperty(container, 'style', {get(){return sp3v}, set() { }, enumerable: true, configurable: true });


        container.setAttribute = tickerContainerSetAttribute;


      }

      return s;
    }
    // if(activator) return null;
    appendChild.call(this, s);
    return s;
  }))(Node.prototype.appendChild);

  /*
  Node.prototype.append = ((append) => (function () {
    // console.log(34,2 )
    return append.apply(this, arguments);
  }))(Node.prototype.append);

  Node.prototype.insertBefore = ((insertBefore) => (function () {
    // console.log(34,3, this.is, this.nodeName, activator)
    // if(activator) return null;
    return insertBefore.apply(this, arguments);
  }))(Node.prototype.insertBefore);

  Node.prototype.insertAfter = ((insertAfter) => (function () {
    // console.log(34,4)
    return insertAfter.apply(this, arguments);
  }))(Node.prototype.insertAfter);

  */




  const fxOperator = (proto, propertyName) => {
    let propertyDescriptorGetter = null;
    try {
      propertyDescriptorGetter = Object.getOwnPropertyDescriptor(proto, propertyName).get;
    } catch (e) { }
    return typeof propertyDescriptorGetter === 'function' ? (e) => propertyDescriptorGetter.call(e) : (e) => e[propertyName];
  };

  const nodeParent = fxOperator(Node.prototype, 'parentNode');
  // const nFirstElem = fxOperator(HTMLElement.prototype, 'firstElementChild');
  const nPrevElem = fxOperator(HTMLElement.prototype, 'previousElementSibling');
  const nNextElem = fxOperator(HTMLElement.prototype, 'nextElementSibling');
  const nLastElem = fxOperator(HTMLElement.prototype, 'lastElementChild');


  /* globals WeakRef:false */

  /** @type {(o: Object | null) => WeakRef | null} */
  const mWeakRef = typeof WeakRef === 'function' ? (o => o ? new WeakRef(o) : null) : (o => o || null); // typeof InvalidVar == 'undefined'

  /** @type {(wr: Object | null) => Object | null} */
  const kRef = (wr => (wr && wr.deref) ? wr.deref() : wr);

  const watchUserCSS = () => {

    if (!CSS.supports('contain-intrinsic-size', 'auto var(--wsr94)')) return;


    const clearContentVisibilitySizing = () => {
      Promise.resolve().then(() => {

        for (const elm of document.querySelectorAll('[wSr93]')) {
          elm.setAttribute('wSr93', '');
        }


      })


    }
    const mutObserver = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if ((mutation.addedNodes || 0).length >= 1) {

          for (const addedNode of mutation.addedNodes) {
            if (addedNode.nodeName === 'SCRIPT') {
              clearContentVisibilitySizing();
              return;
            }

          }
        }
        if ((mutation.remove || 0).length >= 1) {

          for (const removedNode of mutation.removedNodes) {

            if (removedNode.nodeName === 'SCRIPT') {
              clearContentVisibilitySizing();
              return;
            }

          }
        }
      }
    });
    mutObserver.observe(document.documentElement, {
      childList: true,
      subtree: false
    })

    mutObserver.observe(document.head, {
      childList: true,
      subtree: false
    })
    mutObserver.observe(document.body, {
      childList: true,
      subtree: false
    });


  }

  let done = 0;
  let main = async (q) => {

    if (done) return;

    if (!q) return;
    let m1 = nodeParent(q);
    let m2 = q;
    if (!(m1 && m1.id === 'item-offset' && m2 && m2.id === 'items')) return;

    done = 1;

    Promise.resolve().then(watchUserCSS);

    addCss();

    const dummy1v = {
      transform: '',
      height: '',
      minHeight: '',
      paddingBottom: '',
      paddingTop: ''
    };
    for (const k of ['toString', 'getPropertyPriority', 'getPropertyValue', 'item', 'removeProperty', 'setProperty']) {
      dummy1v[k] = ((k) => (function () { const style = this[sp7]; return style[k](...arguments); }))(k)
    }



    const dummy1p = phFn(dummy1v);
    const sp1v = new Proxy(m1.style, dummy1p);
    const sp2v = new Proxy(m2.style, dummy1p);
    Object.defineProperty(m1, 'style', { get() { return sp1v }, set() { }, enumerable: true, configurable: true });
    Object.defineProperty(m2, 'style', { get() { return sp2v }, set() { }, enumerable: true, configurable: true });
    m1.removeAttribute("style");
    m2.removeAttribute("style");

    let lastClick = 0;
    document.addEventListener('click', (evt) => {
      if (!evt.isTrusted) return;
      const target = ((evt || 0).target || 0)
      if (target.id === 'show-more') {
        if (target.nodeName !== 'YT-ICON-BUTTON') return;

        if (Date.now() - lastClick < 80) return;
        requestAnimationFrame(() => {
          lastClick = Date.now();
          target.click();
        })
      }

    })

    let btnShowMoreWR = null;


    const clickShowMore = () => {
      let btnShowMore = kRef(btnShowMoreWR);
      if (!btnShowMore || !btnShowMore.isConnected) {
        btnShowMore = document.querySelector('#show-more.yt-live-chat-item-list-renderer');
        btnShowMoreWR = mWeakRef(btnShowMore);
      }
      if (btnShowMore) btnShowMore.click();
    };

    let hasFirstShowMore = false;

    const visObserver = new IntersectionObserver((entries) => {

      for (const entry of entries) {

        const target = entry.target;
        if (!target) continue;
        let isVisible = entry.isIntersecting === true && entry.intersectionRatio > 0.5;
        if (isVisible) {
          target.style.setProperty('--wsr94', entry.boundingClientRect.height + 'px');
          target.setAttribute('wSr93', 'visible');
          if (nNextElem(target) === null) {

            if (dateNow() - lastScroll < 80) {
              lastLShow = 0;
              lastScroll = 0;
              Promise.resolve().then(clickShowMore);
            } else {
              lastLShow = dateNow();
            }
          } else if (!hasFirstShowMore) {
            // implement inside visObserver to ensure there is sufficient delay
            hasFirstShowMore = true;
            requestAnimationFrame(() => {
              // page visibly ready -> load the fresh comments
              clickShowMore();
              activator = true;
            });
          }
        }
        else if (target.getAttribute('wSr93') === 'visible') {

          target.style.setProperty('--wsr94', entry.boundingClientRect.height + 'px');
          target.setAttribute('wSr93', 'hidden');
        }

      }

    }, {
      /*
  root: items,
  rootMargin: "0px",
  threshold: 1.0,
  */
      root: document.querySelector('#item-scroller'), // nullable
      rootMargin: "0px",
      threshold: [0.0, 1.0],
    });

    //m2.style.visibility='';

    const mutFn = (items)=>{
      let node = nLastElem(items);
      for (; node !== null; node = nPrevElem(node)) {
        if (node.hasAttribute('wSr93')) break;
        node.setAttribute('wSr93', '');
        visObserver.observe(node);
      }
    }

    const mutObserver = new MutationObserver((mutations) => {
      const items = (mutations[0] || 0).target;
      if (!items) return;
      mutFn(items);
    });
    mutObserver.observe(m2, {
      childList: true,
      subtree: false
    });
    mutFn(m2);


    /** @type {HTMLElement} */
    let c1 = nPrevElem(m1);
    if (c1 && c1.id === "live-chat-banner") {
      let rsObserver = new ResizeObserver((entries) => {

        for (const entry of entries) {
          const target = entry.target;
          if (target && target.id === "live-chat-banner") {
            let p = entry.borderBoxSize ? (entry.borderBoxSize[0] || 0).blockSize : 0;
            let c1h = p > entry.contentRect.height ? p : entry.contentRect.height + 16;
            document.documentElement.style.setProperty('--items-top-padding', (Math.ceil(c1h / 2) * 2) + 'px');
          }
        }

      });
      rsObserver.observe(c1);
    }

    let maxScrollTop = -1;
    document.addEventListener('scroll', (evt) => {

      if (!evt || !evt.isTrusted) return;

      const scrollTop = evt.target.scrollTop;
      if (scrollTop >= maxScrollTop) {
        maxScrollTop = scrollTop;

        if (dateNow() - lastLShow < 80) {
          lastLShow = 0;
          lastScroll = 0;
          Promise.resolve().then(clickShowMore);
        } else {
          lastScroll = dateNow();
        }
      } else {
        lastScroll = 0;
      }


    }, { passive: true, capture: true }) // support contain => support passive

  };



  function onReady() {
    let tmObserver = new MutationObserver(() => {


      let q = document.querySelector('#item-offset.style-scope.yt-live-chat-item-list-renderer > #items.style-scope.yt-live-chat-item-list-renderer');

      if (q) {
        tmObserver.disconnect();
        tmObserver.takeRecords();
        tmObserver = null;
        Promise.resolve(q).then((q) => {
          // confirm Promis.resolve() is resolveable
          // execute main without direct blocking
          main(q);
        })
      }

    });

    tmObserver.observe(document.body, {
      childList: true,
      subtree: true
    });

  }



  if (document.readyState != 'loading') {
    onReady();
  } else {
    window.addEventListener("DOMContentLoaded", onReady, false);
  }


})({ Promise, requestAnimationFrame });