thisvid infinite scroll and filter

infinite scroll, filter: public, private, duration, positive/negative tags

Verzia zo dňa 31.01.2024. Pozri najnovšiu verziu.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         thisvid infinite scroll and filter
// @license      MIT
// @namespace    http://tampermonkey.net/
// @version      2.3
// @description  infinite scroll, filter: public, private, duration, positive/negative tags
// @author       smartacephale
// @match        https://thisvid.com/
// @match        https://thisvid.com/latest-updates/*
// @match        https://thisvid.com/tags/*
// @match        https://thisvid.com/categories/*
// @match        https://thisvid.com/*/?q=*
// @match        https://thisvid.com/members/*
// @match        https://thisvid.com/videos/*
// @icon         https://i.imgur.com/LAhknzo.jpeg
// @grant        GM_addStyle
// @run-at       document-end
// ==/UserScript==

const logo = `
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░║▓▓▓▀░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▓▓▓▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░░░░░░░░╫█▓▓▓▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░░░░░░░▒▒▒▒▒░▒░░░░░░░░░░▒▒▒▒▒▒@@▒▒▒▒╖░▓▓▓▓▓▒░░░░░░░░░░░░░▒▒▒▒▒░░░░░░░░░░░░░
░░░░░░░░░▒▒▒▒▒▒░▒░░░░░░░░░░░░░░░▒▒▒▒╣╣╢╢╣╣╣▓▓▓▓▓▓▓▓▓▓▓╣╣▒▒▒▒▒░░░░░░░░▒▒░░░░░░░░░
░░░░░░░░▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒╢╢╫▓▓▓▓▓▓▓▓▓▓▓▓▓╣▒▒▒░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒╢▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░▒▒▒▒╫▓▓▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒╫▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░░▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒╫▓▓▓▓▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░░▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒╢▓▓▓▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒╢╣▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒╣▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒╣╣╣▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒╣╣▒▒▒╢▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░▒░▒▒▒▒▒▒▒╢╢╣▒▒╢╣▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒╢╣╣▒▒╢▓▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒╢╫╣╣▒▒╢▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░
▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░▒▒╣╢╣▒▒▒▒▓╣▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░
▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒░▒▒▒▒▒▒╫╣▒▒▒▒▒╫╣▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░▒▒╢╢▒▒╢╣╣╢╫╣╣▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░
▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒╢╫╣╣▒╢╣▒▒▒╣▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒╢▒▒▒╢▓▓╣▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▓▓╣▓╣▒▒▒▒░▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒░▒░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░▒▒▒╢▓▓▓╬▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒╢▓╣▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒╣╣▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒╣╣╣▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒╣╢╣▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒╢▒╣▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒╢▓▓╣╫▓╣▒░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒╫▓▓▓▓▓▓╣▒▒░░░░░░░░░░░░░░░░░░░░░░░░░▒░░░░░░
▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒╫▓▓▓▓▓▓▓▓▓╣╣▒▒░░░░░░░░░░░░░░░░░░░░▒▒░░░░░░░,
▒▒▒▒▒▒▒▒╣▒╣▒▒▒░▒░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓╣╫╣▒▒░░░░░░░░░░░░░░▒▒░░░░░░░░░░
▒▒▒▒▒▒▒▒╢╣╢╢▓▓╣▒▒▒▒▒▒▒▒▒░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓█▓▓▓▓▓╣▒▒▒▒▒▒▒░░░▒▒▒▒▒░░░░░░░░░░░
░░░░░░░░░░░░▒▒▒╨╨╢▒▒▒▒▒▒▒▒▒▒╨╨╜╜▒▒▒▒▒▒╜▒▒╨╨╨╨╨╨╨╨╨╨╨▀▒╫▒▒╜╣╫╝,░▒▒░]▒▒▒▒╨▒▒░╜Ñ▒╓─`;

(function () {
  // biome-ignore lint/suspicious/noRedundantUseStrict: <explanation>
  'use strict';
  console.clear();
  console.log(logo);

  // biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
  class Utils {
    static $ = (x, e = document.body) => e.querySelector(x);
    static $$ = (x, e = document.body) => e.querySelectorAll(x);

    static findElementsBetweenIds = (startId, endId) => {
      const startElement = document.getElementById(startId);
      const endElement = document.getElementById(endId);

      if (!startElement || !endElement) {
        return [];
      }

      const elementsBetween = [];
      let currentElement = startElement.nextElementSibling;

      while (currentElement && currentElement !== endElement) {
        elementsBetween.push(currentElement);
        currentElement = currentElement.nextElementSibling;
      }

      return elementsBetween;
    };

    static parseHTML = (s) => {
      const t = document.createElement('html');
      t.innerHTML = s;
      return t;
    };

    static fetchHtml = (url) =>
      fetch(url).then((r) => r.text()).then((h) => parseHTML(h));

    static parseCSSUrl = (s) => s.match(/(?<=\(").*(?="\))/)[0];

    static timeToSeconds = (t) =>
      (t.match(/\d+/gm) || ['0'])
        .reverse()
        .map((s, i) => parseInt(s) * 60 ** i)
        .reduce((a, b) => a + b) || 0;

    static circularShift = (n, c = 6, s = 1) => (n + s) % c || c;

    static isElementInViewport = (el) => {
      const rect = el.getBoundingClientRect();
      return (
        rect.top >= 0 &&
        rect.left >= 0 &&
        rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
        rect.right <= (window.innerWidth || document.documentElement.clientWidth)
      );
    };

    static toggleClass = (element, className, condition) => {
      if (condition) {
        element.classList.add(className);
      } else {
        element.classList.remove(className);
      }
    };

    static parseDOM = (html) =>
      new DOMParser().parseFromString(html, 'text/html').body.firstChild;

    static parseIntegerOr = (n, or) =>
      Number.isInteger(parseInt(n)) ? parseInt(n) : or;
  }

  const {
    $,
    $$,
    findElementsBetweenIds,
    fetchHtml,
    parseHTML,
    parseDOM,
    parseCSSUrl,
    circularShift,
    isElementInViewport,
    timeToSeconds,
    toggleClass,
    parseIntegerOr,
  } = Utils;

  class Tick {
    constructor(interval) {
      this.interval = interval;
    }

    start(callback) {
      if (this.ticker) {
        this.stop();
      }
      callback();
      this.ticker = setInterval(callback, this.interval);
    }

    stop() {
      clearInterval(this.ticker);
      this.ticker = false;
    }
  }

  class ReactiveLocalStorage {
    constructor(data) {
      if (data) {
        Object.assign(this, data);
        this.observeProps(this);
      }
    }

    getLS(prop) {
      return JSON.parse(localStorage.getItem(prop));
    }

    setLS(prop, value) {
      localStorage.setItem(prop, JSON.stringify(value));
    }

    toObservable(obj, prop) {
      const lsvalue = this.getLS(prop);
      let value = lsvalue !== null ? lsvalue : obj[prop];

      Object.defineProperty(obj, prop, {
        get() {
          return value;
        },
        set(newValue) {
          this.setLS(prop, newValue);
          value = newValue;
        },
      });
    }

    observeProps(obj) {
      for (const [key, _] of Object.entries(obj)) {
        this.toObservable(obj, key);
      }
    }
  }

  const SCROLL_RESET_DELAY = 500;
  const ANIMATION_DELAY = 750;

  class DomManager {
    constructor() {
      this.data = new Map();
      this.container = this.createThumbsContainer();
      this.observables = [];
      this.lazyImageObserver = this.createLazyImageObserver();
    }

    setObservation() {
      for (const observable of this.observables) {
        this.lazyImageObserver.observe(observable);
      }
      this.observables = [];
    }

    createLazyImageObserver() {
      return new IntersectionObserver((entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting && !this.isFiltered(entry.target)) {
            this.lazyImageObserver.unobserve(entry.target);
            this.delazify(entry.target);
          }
        }
      });
    }

    delazify(el) {
      const img = el.firstElementChild.firstElementChild;
      img.src = img.getAttribute('lazy-loading');
      img.removeAttribute('lazy-loading');
    }

    isFiltered(el) {
      return el.className.includes('filtered');
    }

    filterPositiveTags = () => {
      for (const [k, v] of this.data.entries()) {
        const containTagsNot = state.filterPositiveTags.some(tag => !k.includes(tag));
        toggleClass(v.element, 'filtered-tag-pos', state.filterPositive && containTagsNot);
      };
    }

    filterNegativeTags = () => {
      for (const [k, v] of this.data.entries()) {
        const containTags = state.filterNegativeTags.some(tag => k.includes(tag));
        toggleClass(v.element, 'filtered-tag-neg', state.filterNegative && containTags);
      };
    }

    thumbIsPrivate(t) {
      return t.firstElementChild.classList.contains('private');
    }

    filterPrivate = (filterPrivate = state.filterPrivate) => {
      for (const v of this.data.values()) {
        toggleClass(v.element, 'filtered-private',
          filterPrivate && this.thumbIsPrivate(v.element))
      };
    }

    filterPublic = (filterPublic = state.filterPublic) => {
      for (const v of this.data.values()) {
        toggleClass(v.element, 'filtered-public',
          filterPublic && !this.thumbIsPrivate(v.element))
      };
    }

    filterByDuration = () => {
      const { filterDurationFrom: from, filterDurationTo: to, filterDuration } = state;
      for (const v of this.data.values()) {
        toggleClass(v.element, 'filtered-duration',
          filterDuration && (v.duration < from || v.duration > to))
      };
    }

    runFilters() {
      if (state.filterPrivate) this.filterPrivate();
      if (state.filterPublic) this.filterPublic();
      if (state.filterDuration) this.filterByDuration();
      if (state.filterPositive) this.filterPositiveTags();
      if (state.filterNegative) this.filterNegativeTags();
    }

    createThumbsContainer() {
      return parseDOM('<div class="thumbs-items"></div>');
    }

    handleLoadedHTML = (htmlPage, mount, useGlobalContainer = true) => {
      const thumbs = $$('.tumbpu', htmlPage);

      const container = !useGlobalContainer ? this.createThumbsContainer() : this.container;

      for (const thumbElement of thumbs) {
        const url = thumbElement.getAttribute('href');
        if (!url || this.data.has(url)) {
          thumbElement.remove();
        } else {
          this.data.set(url, {
            element: thumbElement,
            duration: timeToSeconds($('.thumb > .duration', thumbElement).textContent)
          });
          const img = $('img', thumbElement);
          const privateEl = $('.private', thumbElement);
          let imgSrc;
          if (privateEl) {
            imgSrc = parseCSSUrl(privateEl.style.background);
            privateEl.style.background = '#000';
          } else {
            imgSrc = img.getAttribute('data-original');
          }
          img.setAttribute('lazy-loading', imgSrc);
          img.src = '';
          img.classList.add('tracking');

          container.appendChild(thumbElement);
          this.observables.push(thumbElement);
        }
      }

      this.runFilters(container);
      this.setObservation();
      mount.before(container);
    };
  }

  class PreviewManager {
    constructor() {
      this.tick = new Tick(ANIMATION_DELAY);
    }

    iteratePreviewImages(src) {
      return src.replace(/(\d)\.jpg$/, (_, n) => `${circularShift(parseInt(n))}.jpg`);
    }

    animatePreview = (e) => {
      const { target: el, type } = e;
      if (el.tagName === 'IMG' && el.classList.contains('tracking')) {
        if (type === 'mouseout') {
          this.tick.stop();
          if (el.getAttribute('orig')) el.src = el.getAttribute('orig');
        }
        if (type === 'mouseover') {
          if (!el.getAttribute('orig')) el.setAttribute('orig', el.src);
          this.tick.start(() => {
            el.src = this.iteratePreviewImages(el.src);
          });
        }
      }
    };

    listen(e) {
      e.addEventListener('mouseout', this.animatePreview);
      e.addEventListener('mouseover', this.animatePreview);
    }
  }

  class PaginationPageManager {
    constructor() {
      this.pagination = $('.pagination');
      this.pagination.style.opacity = 0;
      handleLoadedHTML(document.body, this.pagination);
      previewManager.listen(this.pagination.parentElement);

      this.offsetLast = this.getOffsetLast();
      this.paginationGenerator = this.createNextPageGenerator();
      this.generatorDone = false;

      this.resetScroller();
      this.tick = new Tick(SCROLL_RESET_DELAY);
      this.fixScrollViewPort();

      this.ui = new UI(true);
      this.setPagIndex = (offset) =>
        this.ui.setPagIndex(offset, this.offsetLast);
      this.setPagIndex(this.getCurrentOffset());
    }

    fixScrollViewPort() {
      this.tick.start(() => {
        if (this.generatorDone) this.tick.stop();
        if (isElementInViewport(this.pagination)) this.generatorConsumer();
      });
    }

    async generatorConsumer() {
      const {
        value: { url, offset } = {},
        done,
      } = this.paginationGenerator.next();
      this.generatorDone = done;
      if (!done) {
        const nextPageHTML = await fetchHtml(url);
        const prevScrollPos = document.documentElement.scrollTop;
        handleLoadedHTML(nextPageHTML, this.pagination);
        this.setPagIndex(offset);
        window.scrollTo(0, prevScrollPos);
      }
    }

    getCurrentOffset() {
      return parseInt(window.location.pathname.split(/(\d+\/)$/)[1] || '1');
    }

    getOffsetLast() {
      return parseInt(
        $('.pagination-next').previousElementSibling.firstElementChild
          .textContent,
      );
    }

    createNextPageGenerator() {
      let { origin, pathname, search } = window.location;
      let offset;
      [pathname, offset = '1'] = pathname.split(/(\d+\/)$/);
      offset = parseInt(offset);
      pathname = pathname === '/' ? '/latest-updates/' : pathname;

      const offsetLast = this.getOffsetLast();
      function* nextPageGenerator() {
        for (let c = offset + 1; c <= offsetLast; c++) {
          const url = `${origin}${pathname}${c}/${search}`;
          console.log(url);
          yield { url, offset: c };
        }
      }

      return nextPageGenerator();
    }

    resetScroller = () => {
      this.infiniteScrollTriggered = false;
      window.dispatchEvent(new CustomEvent('scroll'));
    };

    infiniteScroll = () => {
      const inViewport = isElementInViewport(this.pagination);
      if (inViewport === this.infiniteScrollTriggered) return;
      this.infiniteScrollTriggered = inViewport;
      if (inViewport) this.generatorConsumer();
    };
  }

  class Router {
    constructor() {
      this.route();
    }

    route() {
      const { pathname } = window.location;
      if ($('.pagination-next')) {
        this.handlePaginationPage();
      } else if (/\/members\/\d+\/$/.test(pathname)) {
        this.handleMemberPage();
      } else if (/\/tag\//.test(pathname) || /\/?q=.*/.test(pathname)) {
        this.handlePageWithVideosButNoPagination();
      } else if (/\/videos\//.test(pathname)) {
        //this.handlePageWithVideosButNoPagination()
      }
    }

    handlePageWithVideosButNoPagination() {
      const vid = $('.tumbpu');
      if (!vid) return;
      handleLoadedHTML(document.body, vid.parentElement);
      previewManager.listen(vid.parentElement);
      const ui = new UI(false);
    }

    handlePaginationPage() {
      this.paginationManager = new PaginationPageManager();
    }

    handleMemberPage() {
      const privates = $('#list_videos_private_videos_items');
      if (privates) {
        const mistakes = findElementsBetweenIds(
          'list_videos_private_videos_items',
          'list_videos_favourite_videos',
        );
        for (const m of mistakes) privates.appendChild(m);
        handleLoadedHTML(privates, privates, false);
      }

      const favorites = $('#list_videos_favourite_videos');
      if (favorites) {
        const mountTo = favorites.firstElementChild.nextElementSibling;
        handleLoadedHTML(favorites, mountTo, false);
      }

      if (privates || favorites) {
        previewManager.listen((privates || favorites).parentElement);
        const ui = new UI(false);
      }
    }
  }

  class UI {
    templateHTML = (haspag) => `
<div id="tapermonkey-app">
<div class="subbox">
    <input type="checkbox" id="filterNegative" name="filterNegative" ${state.filterNegative ? 'checked' : ''}/>
    <label for="filterNegative">negative filter</label>
    <textarea id="filterNegativeText" placeholder="tag1 tag2 ...">${state.filterNegativeTags.join(',')}</textarea>
</div>
<div class="subbox">
    <input type="checkbox" id="filterPositive" name="filterPositive" ${state.filterPositive ? 'checked' : ''}/>
    <label for="filterPositive">positive filter</label>
    <textarea id="filterPositiveText" placeholder="tag1 tag2 ...">${state.filterPositiveTags.join(',')}</textarea>
</div>
<div class="subbox">
  <input type="checkbox" id="filterPrivate" name="filterPrivate" ${state.filterPrivate ? 'checked' : ''}/>
  <label for="filterPrivate">filter private</label>
  <input type="checkbox" id="filterPublic" name="filterPublic" ${state.filterPublic ? 'checked' : ''}/>
  <label for="filterPublic">filter public</label>
  ${haspag ? '<span id="pagIndex">0/0</span>' : ''}
</div>
<div class="subbox">
  <input type="checkbox" id="filterl" name="filterl" ${state.filterDuration ? 'checked' : ''}/>
  <label for="filterl">filter duration seconds</label>

  <label for="from">from</label>
  <input type="number" placeholder="min sec" step="10"
    min="0" max="72000" id="minL" name="from" value=${state.filterDurationFrom} />
  <label for="to">to</label>
  <input type="number" placeholder="max sec" step="10"
    min="0" max="72000" id="maxL" name="to" value=${state.filterDurationTo} />
</div>
</div>`;

    constructor(haspag = true) {
      document.body.appendChild(parseDOM(this.templateHTML(haspag)));
      this.tapermonkeyAppTemplate = document.querySelector('#tapermonkey-app');
      this.control();
    }

    setPagIndex(index, total) {
      $('#pagIndex').innerText = `${index}/${total}`;
    }

    stringToTags(s) {
      return s.split(",").map(s => s.trim()).filter(s => s.length > 0);
    }

    control() {
      this.tapermonkeyAppTemplate.addEventListener('input', (e) => {
        const { id, value } = e.target;
        if (id === 'filterNegativeText') {
          state.filterNegativeTags = this.stringToTags(value);
          filterNegativeTags()
        }
        if (id === 'filterPositiveText') {
          state.filterPositiveTags = this.stringToTags(value);
          filterPositiveTags();
        }
      });

      this.tapermonkeyAppTemplate.addEventListener('click', (e) => {
        const { id, checked, value } = e.target;
        if (id === 'filterPublic') {
          state.filterPublic = checked;
          filterPublic();
        }
        if (id === 'filterPrivate') {
          state.filterPrivate = checked;
          filterPrivate();
        }
        if (id === 'filterNegative') {
          state.filterNegative = checked;
          filterNegativeTags()
        }
        if (id === 'filterPositive') {
          state.filterPositive = checked;
          filterPositiveTags();
        }
        if (id === 'filterl') {
          state.filterDuration = checked;
          filterByDuration();
        }
        if (id === 'minL') {
          state.filterDurationFrom = parseIntegerOr(value, state.filterDurationFrom);
          filterByDuration();
        }
        if (id === 'maxL') {
          state.filterDurationTo = parseIntegerOr(value, state.filterDurationTo);
          filterByDuration();
        }
      });
    }
  }

  const state = new ReactiveLocalStorage({
    filterDurationFrom: 0,
    filterDurationTo: 600,
    filterDuration: false,
    filterPrivate: false,
    filterPublic: false,
    infiniteScrollTriggered: false,
    filterNegativeTags: [],
    filterNegative: false,
    filterPositiveTags: [],
    filterPositive: false
  });

  const { filterPrivate, filterPublic, filterByDuration, filterPositiveTags, filterNegativeTags, handleLoadedHTML } = new DomManager();
  const previewManager = new PreviewManager();
  const router = new Router();

  const tampermonkeyCSS = `
#tapermonkey-app {
  background: #151515;
  padding: 10px;
  position: fixed;
  z-index: 9999;
  bottom: 10px;
  right: 10px;
  border-radius: 15px;
  width: max-content;
  box-shadow: 20px 20px 60px #000000, -20px -20px 60px #000000;
}
#tapermonkey-app .subbox {
  background: #2c2c2c;
  border-radius: 5px;
  padding: 4px;
  margin: 6px;
  user-select: none;
  display: flex;
}
#tapermonkey-app .subbox input[type=number] {
  width: 5rem;
  background: #26282b;
}
#tapermonkey-app .subbox input[type=checkbox] { margin-left: 5px; }
#tapermonkey-app .subbox label { margin: 0 10px 0 0; }
#pagIndex { text-align: end; margin-right: 5px; flex: 1;}
#tapermonkey-app textarea { flex: 1; height: 2rem; padding: 6px; }
#tapermonkey-app .subbox * {
  padding-left: 8px;
  float: none;
  width: auto;
  font-family: monospace;
  font-size: 0.8rem;
  align-self: center;
  color: #969696;
}

.tracking { content-visibility: auto; }
.filtered-private, .filtered-duration, .filtered-public, .filtered-tag-pos, .filtered-tag-neg
        { display: none !important; } `;

  GM_addStyle(tampermonkeyCSS);
})();