YouTube Subscription Page Filter

Applies filter to the subscription page's video list on YouTube.

// ==UserScript==
// @name         YouTube Subscription Page Filter
// @namespace    http://xiniha.github.io
// @version      1.0.2
// @description  Applies filter to the subscription page's video list on YouTube.
// @author       XiNiHa
// @match        https://www.youtube.com/feed/subscriptions*
// @grant        none
// @require      https://cdn.jsdelivr.net/npm/vue
// ==/UserScript==

(function () {
  const listElement = document.querySelector(
    "ytd-section-list-renderer[page-subtype=subscriptions]>#contents"
  );

  let appDiv = document.createElement("div");
  appDiv.id = "app";
  let placeholder = document.createElement("div");
  placeholder.style.height = "50px";
  listElement.parentElement.prepend(placeholder);
  listElement.parentElement.prepend(appDiv);

  window.vapp = new Vue({
    el: "#app",
    template: `
      <div id="app" :style="styles.app">
        <div v-if="filters.length > 0" v-for="(filter, i) in filters" :key="i" :style="styles.filterItem">
          {{filter.display}}
          <button type="button" :style="styles.filterBtn" @click="filters.splice(i, 1)">x</button>
        </div>
        <div v-if="filters.length == 0" :style="styles.filterItem">No Filters Applied!</div>
        <div :style="styles.filterItem">
          <select v-model="type">
            <option value="title">Title</option>
            <option value="channel">Channel</option>
          </select>
          <input v-model="expr" placeholder="Query Text or RegEx">
          <button type="button" :style="styles.filterBtn" @click="addFilter">+</button>
        </div>
      </div>
    `,
    data: {
      filters: [],
      type: "title",
      expr: "",
      styles: {
        app: {
          width: "80%",
          height: "50px",
          display: "flex",
          fontFamily: "sans-serif",
          fontSize: "16px",
          flexDirection: "row",
          justifyContent: "center",
          alignItems: "center",
          position: "fixed",
          top: "56px",
          zIndex: "10",
          background: "#f9f9f9"
        },
        filterItem: {
          height: "30px",
          margin: "0 4px",
          padding: "4px 8px 4px 8px",
          borderRadius: "30px",
          backgroundColor: "#e0e0e0",
          cursor: "default"
        },
        filterBtn: {
          background: "#aaa",
          border: 0,
          height: "20px",
          width: "20px",
          borderRadius: "50%",
          padding: 0,
          margin: "0 -4px 0 4px",
          cursor: "pointer",
        }
      }
    },
    watch: {
      filters() {
        this.updateFilter()
      }
    },
    methods: {
      addFilter() {
        switch(this.type){
          case "title":
            this.filters.push(this.createTitleFilter(this.expr))
            break;
          case "channel":
            this.filters.push(this.createChannelFilter(this.expr))
            break;
        }
      },
      updateFilter() {
        for (let section of document.querySelectorAll(
          "ytd-section-list-renderer[page-subtype=subscriptions]>#contents>ytd-item-section-renderer"
        )) {
          let targets = Array.prototype.map.call(
            section.querySelectorAll("ytd-grid-video-renderer"),
            (element) => {
              return {
                title: element.querySelector("#video-title").title,
                channel: element.querySelector(
                  "yt-formatted-string.ytd-channel-name>a"
                ).innerText,
                element: element,
              };
            }
          );

          let atLeastOne = false;

          for (let target of targets) {
            if (this.testFilters(target, this.filters)) atLeastOne = true;
          }

          if (this.filters.length === 0) continue;
          section.style.display = atLeastOne ? "block" : "none";
        }
      },
      testFilters(target, filters) {
        for (let filter of filters) {
          if (!filter.test(target)) {
            target.element.style.display = "none";
            return false;
          }
        }
        target.element.style.display = "inline-block";
        return true;
      },
      createORFilter(filter1, filter2) {
        return {
          get display() {
            return this.filter1.display + " OR " + this.filter2.display;
          },
          filter1,
          filter2,
          test: (target) => filter1.test(target) || filter2.test(target),
        };
      },
      createTitleFilter(initialExpr) {
        return {
          get display() {
            return `Title - ${this.expr.toString()}`;
          },
          expr: initialExpr,
          test: function (target) {
            return target.title.match(this.expr);
          },
        };
      },
      createChannelFilter(initialExpr) {
        return {
          get display() {
            return `Channel - ${this.expr.toString()}`;
          },
          expr: initialExpr,
          test: function (target) {
            return target.channel.match(this.expr);
          },
        };
      }
    },
  });

  const observer = new MutationObserver((list, observer) => {
    for (let mutation of list) {
      if (
        mutation.type === "childList" &&
        mutation.addedNodes[0].nodeName == "YTD-ITEM-SECTION-RENDERER"
      ) {
        window.vapp.updateFilter();
      }
    }
  });

  observer.observe(listElement, { childList: true });
})();