DMM - Add Trash Guide Regex Buttons

Adds buttons to Debrid Media Manager for applying Trash Guide regex patterns.

Fra 31.08.2025. Se den seneste versjonen.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name          DMM - Add Trash Guide Regex Buttons
// @version       2.2.0
// @description   Adds buttons to Debrid Media Manager for applying Trash Guide regex patterns.
// @author        Journey Over
// @license       MIT
// @match         *://debridmediamanager.com/*
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@af0d3fcda72bded872240b37fac343160cc6dfd1/libs/dmm/button-data.min.js
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@5f2cbff53b0158ca07c86917994df0ed349eb96c/libs/gm/gmcompat.js
// @grant         GM.getValue
// @grant         GM.setValue
// @icon          https://www.google.com/s2/favicons?sz=64&domain=debridmediamanager.com
// @homepageURL   https://github.com/StylusThemes/Userscripts
// @namespace https://greasyfork.org/users/32214
// ==/UserScript==

(function() {
  'use strict';

  /* ===========================
      Configuration
      - selectors, timeouts and shared CSS class prefix
      =========================== */
  const CONFIG = {
    CONTAINER_SELECTOR: '.mb-2',
    RELEVANT_PAGE_RX: /debridmediamanager\.com\/(movie|show)\/[^\/]+/,
    DEBOUNCE_MS: 120,
    MAX_RETRIES: 20,
    CSS_CLASS_PREFIX: 'dmm-tg',
  };

  // external data expected to be an array on window.DMM_BUTTON_DATA
  const BUTTON_DATA = Array.isArray(window?.DMM_BUTTON_DATA) ? window.DMM_BUTTON_DATA : [];

  /* ===========================
      Quality tokens
      Map of checkbox key -> display name and one-or-more token strings.
      Selected keys are persisted in GM storage and rendered as a single
      parenthesized group like "(1080p|4k|2160p)" appended to the input.
      =========================== */
  // quality tokens map: { key, name, values[] }
  const QUALITY_TOKENS = [
    { key: '720p', name: '720p', values: ['720p'] },
    { key: '1080p', name: '1080p', values: ['1080p'] },
    { key: '4k', name: '4k', values: ['4k', '2160p'] },
    { key: 'dv', name: 'Dolby Vision', values: ['dovi', 'dv', 'dolby', 'vision'] },
    { key: 'x264', name: 'x264', values: ['[xh][\\s._-]?264'] },
    { key: 'x265', name: 'x265', values: ['[xh][\\s._-]?265', '\\bHEVC\\b'] },
    { key: 'hdr', name: 'HDR', values: ['hdr'] },
    { key: 'remux', name: 'Remux', values: ['remux'] }
  ];

  const STORAGE_KEY = 'dmm_tg_selected_qualities';

  /* ===========================
      Small DOM & input helpers
      - qs/qsa: query helpers
      - isVisible: small visibility check
      - setInputValueReactive: set an input value and emit events so frameworks pick it up
      =========================== */
  const qs = (sel, root = document) => root.querySelector(sel);
  const qsa = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const isVisible = el => !!(el && el.offsetParent !== null && getComputedStyle(el).visibility !== 'hidden');

  const getNativeValueSetter = (el) => {
    const proto = el instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype;
    const desc = Object.getOwnPropertyDescriptor(proto, 'value');
    return desc && desc.set;
  };

  const setInputValueReactive = (el, value) => {
    const nativeSetter = getNativeValueSetter(el);
    if (nativeSetter) {
      nativeSetter.call(el, value);
    } else {
      el.value = value;
    }
    // focus + move cursor to end
    try {
      el.focus();
    } catch (e) {}
    try {
      if (typeof el.setSelectionRange === 'function') el.setSelectionRange(value.length, value.length);
    } catch (e) {}

    // events
    el.dispatchEvent(new Event('input', {
      bubbles: true
    }));
    el.dispatchEvent(new Event('change', {
      bubbles: true
    }));
    // React internals fallback
    try {
      if (el._valueTracker && typeof el._valueTracker.setValue === 'function') {
        el._valueTracker.setValue(value);
      }
    } catch (e) {}
  };

  // debounce helper
  const debounce = (fn, ms) => {
    let t;
    return (...args) => {
      clearTimeout(t);
      t = setTimeout(() => fn(...args), ms);
    };
  };

  /* ===========================
      Inject styles for buttons, menus and the quality bar
      =========================== */
  (function injectStyles() {
    const p = CONFIG.CSS_CLASS_PREFIX;
    const css = `
      /* Button style aligned with site's small chips/buttons */
      .${p}-btn{cursor:pointer;display:inline-flex;align-items:center;gap:.35rem;margin-right:.5rem;padding:.25rem .5rem;font-size:12px;line-height:1;border-radius:.375rem;color:#e6f0ff;background:rgba(15,23,42,.5);border:1px solid rgba(59,130,246,.55);box-shadow:none;user-select:none;white-space:nowrap;}
      .${p}-btn:hover{background:rgba(59,130,246,.08);}
      .${p}-btn:focus{outline:2px solid rgba(59,130,246,.18);outline-offset:2px;}
      .${p}-chev{width:12px;height:12px;color:rgba(226,240,255,.95);margin-left:.15rem;display:inline-block;transition:transform 160ms ease;transform-origin:center;}
      .${p}-btn[aria-expanded="true"] .${p}-chev{transform:rotate(180deg);}
      .${p}-menu{position:absolute;min-width:10rem;background:#111827;color:#fff;border:1px solid rgba(148,163,184,.06);border-radius:.375rem;box-shadow:0 6px 18px rgba(2,6,23,.6);padding:.25rem 0;z-index:9999;display:none;}
      .${p}-menu::before{content:"";position:absolute;top:-6px;left:12px;width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid #111827;}
      .${p}-item{padding:.45rem .75rem;cursor:pointer;font-size:13px;white-space:nowrap;border-bottom:1px solid rgba(255,255,255,.03);}
      .${p}-item:last-child{border-bottom:none;}
      .${p}-item:hover{background:#1f2937;}
      /* quality toggle area */
      .${p}-quality{display:flex;flex-wrap:wrap;gap:.35rem;padding:.35rem .5rem;border-top:1px solid rgba(255,255,255,.03);}
      .${p}-quality .${p}-qual{display:inline-flex;align-items:center;gap:.4rem;padding:.25rem .4rem;background:transparent;border-radius:.25rem;cursor:pointer;font-size:12px;color:rgba(226,240,255,.9);}
      .${p}-qual input{width:14px;height:14px;vertical-align:middle}
      .${p}-qual:hover{background:rgba(255,255,255,.02)}
      /* wrapper layout */
      .${p}-container{display:flex;align-items:center;justify-content:space-between;gap:0.75rem;flex-wrap:wrap}
      .${p}-container > button{margin:0.25rem 0}
      .${p}-buttons{display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap}
      .${p}-quality{margin-left:auto}
      @media (max-width:720px){
        .${p}-container{flex-direction:column;align-items:stretch}
        .${p}-quality{margin-left:0}
      }
    `;
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
  })();

  /* ===========================
      ButtonManager
      Creates pattern buttons + dropdowns, a single shared quality bar, and
      wires all interaction logic (positioning, apply/remove tokens, sync).
      =========================== */
  class ButtonManager {
    constructor() {
      /** Map<string, {button:HTMLElement, menu:HTMLElement}> */
      this.dropdowns = new Map();
      this.container = null;
      this.openMenu = null;
      this.qualityBar = null;
      this.wrapper = null;
      this.documentClickHandler = this.onDocumentClick.bind(this);
      this.resizeHandler = this.onWindowResize.bind(this);
      this.keydownHandler = this.onDocumentKeydown.bind(this);
    }

    cleanup() {
      // remove elements & listeners
      for (const { button, menu } of this.dropdowns.values()) {
        button.remove();
        menu.remove();
      }
      this.dropdowns.clear();
      this.container = null;
      this.openMenu = null;
      if (this.qualityBar) {
        this.qualityBar.remove();
        this.qualityBar = null;
      }
      if (this.wrapper) {
        this.wrapper.remove();
        this.wrapper = null;
      }
      document.removeEventListener('click', this.documentClickHandler, true);
      document.removeEventListener('keydown', this.keydownHandler);
      window.removeEventListener('resize', this.resizeHandler);
    }

    initialize(container) {
      if (!container || this.container === container) return;
      this.cleanup();
      this.container = container;

      BUTTON_DATA.forEach(spec => {
        const name = String(spec.name || 'Pattern');
        if (this.dropdowns.has(name)) return;
        const btn = this._createButton(name);
        const menu = this._createMenu(spec.buttonData || [], name);
        document.body.appendChild(menu);
        // ensure wrapper exists and append buttons into it
        if (!this.wrapper) {
          const w = document.createElement('div');
          w.className = `${CONFIG.CSS_CLASS_PREFIX}-container`;
          // inner left button group
          const btns = document.createElement('div');
          btns.className = `${CONFIG.CSS_CLASS_PREFIX}-buttons`;
          w.appendChild(btns);
          this.wrapper = w;
          try {
            this.container.appendChild(this.wrapper);
          } catch (e) {
            document.body.appendChild(this.wrapper);
          }
        }
        // append into the left button group
        const btnGroup = this.wrapper.querySelector(`.${CONFIG.CSS_CLASS_PREFIX}-buttons`) || this.wrapper;
        btnGroup.appendChild(btn);
        this.dropdowns.set(name, {
          button: btn,
          menu
        });
        // attach button click
        btn.addEventListener('click', (ev) => {
          ev.stopPropagation();
          this.toggleMenu(name);
        });
      });

      // build a single shared quality checkbox bar; state is loaded from GM storage
      if (!this.qualityBar) {
        const qualWrap = document.createElement('div');
        qualWrap.className = `${CONFIG.CSS_CLASS_PREFIX}-quality`;
        // create checkboxes, then load stored selection
        QUALITY_TOKENS.forEach(tok => {
          const lab = document.createElement('label');
          lab.className = `${CONFIG.CSS_CLASS_PREFIX}-qual`;
          const cb = document.createElement('input');
          cb.type = 'checkbox';
          cb.dataset.key = tok.key;
          cb.addEventListener('change', (ev) => {
            ev.stopPropagation();
            // update stored selection
            try {
              GMC.getValue(STORAGE_KEY, []).then(cur => {
                const ks = new Set(Array.isArray(cur) ? cur : []);
                if (cb.checked) ks.add(tok.key);
                else ks.delete(tok.key);
                const updated = Array.from(ks);
                GMC.setValue(STORAGE_KEY, updated).catch(() => {});
                // apply group to current input
                this.applySelectedQualityToInput();
              }).catch(() => {});
            } catch (e) {}
          });
          const span = document.createElement('span');
          span.textContent = tok.name;
          lab.appendChild(cb);
          lab.appendChild(span);
          qualWrap.appendChild(lab);
        });
        this.qualityBar = qualWrap;
        try {
          if (this.wrapper) this.wrapper.appendChild(this.qualityBar);
          else this.container.appendChild(this.qualityBar);
        } catch (e) {
          document.body.appendChild(this.qualityBar);
        }
        // async: load stored selection, set initial checkbox states, then apply
        try {
          GMC.getValue(STORAGE_KEY, []).then(stored => {
            try {
              const boxes = this.qualityBar.querySelectorAll(`.${CONFIG.CSS_CLASS_PREFIX}-qual input`);
              boxes.forEach(b => {
                const k = String(b.dataset.key || '');
                b.checked = Array.isArray(stored) && stored.includes(k);
              });
            } catch (e) {}
            this.applySelectedQualityToInput();
          }).catch(() => {});
        } catch (e) {}
      }

      // global handlers for closing
      document.addEventListener('click', this.documentClickHandler, true);
      document.addEventListener('keydown', this.keydownHandler);
      window.addEventListener('resize', this.resizeHandler);
    }

    onDocumentKeydown(e) {
      if (!this.openMenu) return;
      if (e.key === 'Escape' || e.key === 'Esc') {
        e.preventDefault();
        this.closeOpenMenu();
      }
    }

    _createButton(name) {
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.className = `${CONFIG.CSS_CLASS_PREFIX}-btn`;
      // label text node for accessibility
      const label = document.createTextNode(name);
      btn.appendChild(label);
      // append small chevron svg for dropdown affordance
      const svgNs = 'http://www.w3.org/2000/svg';
      const chev = document.createElementNS(svgNs, 'svg');
      chev.setAttribute('viewBox', '0 0 20 20');
      chev.setAttribute('aria-hidden', 'true');
      chev.setAttribute('class', `${CONFIG.CSS_CLASS_PREFIX}-chev`);
      chev.innerHTML = '<path d="M6 8l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />';
      btn.appendChild(chev);
      btn.setAttribute('aria-haspopup', 'true');
      btn.setAttribute('aria-expanded', 'false');
      btn.tabIndex = 0;
      return btn;
    }

    _createMenu(items = [], name) {
      const menu = document.createElement('div');
      menu.className = `${CONFIG.CSS_CLASS_PREFIX}-menu`;
      menu.dataset.owner = name;
      items.forEach(it => {
        const item = document.createElement('div');
        item.className = `${CONFIG.CSS_CLASS_PREFIX}-item`;
        item.textContent = it.name || it.value || 'apply';
        item.addEventListener('click', (ev) => {
          ev.stopPropagation();
          this.onSelectPattern(it.value, it.name);
          this.closeOpenMenu();
        });
        menu.appendChild(item);
      });

      // quality toggles are provided as a single shared bar created elsewhere
      return menu;
    }

    toggleMenu(name) {
      const entry = this.dropdowns.get(name);
      if (!entry) return;
      const { button, menu } = entry;
      if (this.openMenu && this.openMenu !== menu) this.openMenu.style.display = 'none';
      if (menu.style.display === 'block') {
        menu.style.display = 'none';
        button.setAttribute('aria-expanded', 'false');
        this.openMenu = null;
      } else {
        this.positionMenuUnderButton(menu, button);
        menu.style.display = 'block';
        button.setAttribute('aria-expanded', 'true');
        // when opening, sync the shared quality checkboxes with the current input value
        this.syncQualityCheckboxes();
        this.openMenu = menu;
      }
    }

    // Toggle a single token in the visible text input without overwriting other tokens.
    onToggleQualityToken(token, enabled) {
      // find a target text-like input similar to onSelectPattern
      const TEXT_SELECTOR = 'input[type="text"], input[type="search"], input[type="url"], input[type="tel"], input[type="email"], textarea';
      let target = null;
      if (this.container) {
        target = this.container.querySelector(TEXT_SELECTOR);
        if (target && !isVisible(target)) target = null;
      }
      if (!target) {
        const cand = qsa(TEXT_SELECTOR);
        target = cand.find(isVisible) || null;
      }
      if (!target) return;

      try {
        const cur = String(target.value || '').trim();
        const tokens = cur ? cur.split(/\s+/) : [];
        const lower = token.toLowerCase();
        const has = tokens.some(t => t.toLowerCase() === lower);
        if (enabled && !has) {
          tokens.push(token);
        } else if (!enabled && has) {
          const idx = tokens.findIndex(t => t.toLowerCase() === lower);
          if (idx > -1) tokens.splice(idx, 1);
        }
        const next = tokens.join(' ');
        setInputValueReactive(target, next);
      } catch (err) {
        console.error('dmm-tg: failed to toggle quality token', err, { token, enabled });
      }
    }

    // Build the parenthesized pipe-separated group from selected keys and
    // insert/replace it in the visible input. If no keys selected, remove any
    // recognized group.
    async applySelectedQualityToInput() {
      const selectedKeys = Array.isArray(await GMC.getValue(STORAGE_KEY, [])) ? await GMC.getValue(STORAGE_KEY, []) : [];
      // build flat list of token strings
      const vals = [];
      selectedKeys.forEach(k => {
        const found = QUALITY_TOKENS.find(t => t.key === k);
        if (found && Array.isArray(found.values)) found.values.forEach(v => vals.push(v));
      });

      // remove duplicates and normalize order
      const uniq = Array.from(new Set(vals));
      const group = uniq.length ? `(${uniq.join('|')})` : '';

      // find text-like target
      const TEXT_SELECTOR = 'input[type="text"], input[type="search"], input[type="url"], input[type="tel"], input[type="email"], textarea';
      let target = null;
      if (this.container) {
        target = this.container.querySelector(TEXT_SELECTOR);
        if (target && !isVisible(target)) target = null;
      }
      if (!target) {
        const cand = qsa(TEXT_SELECTOR);
        target = cand.find(isVisible) || null;
      }
      if (!target) return;

      try {
        let cur = String(target.value || '').trim();
        // remove any existing parenthesis group that looks like a quality group
        // we'll match the last parenthesized group in the string (common for appended groups)
        const lastParenIdx = cur.lastIndexOf('(');
        if (lastParenIdx > -1) {
          const closing = cur.indexOf(')', lastParenIdx);
          if (closing > lastParenIdx) {
            // extract inner and see if it contains any of known tokens
            const inner = cur.slice(lastParenIdx + 1, closing);
            const innerParts = inner.split(/\|/).map(s => s.trim().toLowerCase()).filter(Boolean);
            const known = QUALITY_TOKENS.flatMap(t => t.values.map(v => v.toLowerCase()));
            const intersects = innerParts.some(p => known.includes(p));
            if (intersects) {
              // remove that group including any whitespace before it
              cur = (cur.slice(0, lastParenIdx).trimEnd());
            }
          }
        }

        // append new group if present
        const next = group ? (cur ? `${cur} ${group}` : group) : cur;
        setInputValueReactive(target, next);
      } catch (err) {
        console.error('dmm-tg: failed to apply quality group', err);
      }
    }

    // Sync checkbox states from GM storage (fallback: parse current input).
    async syncQualityCheckboxes() {
      if (!this.qualityBar) return;
      try {
        const stored = Array.isArray(await GMC.getValue(STORAGE_KEY, [])) ? await GMC.getValue(STORAGE_KEY, []) : null;
        const boxes = this.qualityBar.querySelectorAll(`.${CONFIG.CSS_CLASS_PREFIX}-qual input`);
        if (stored) {
          boxes.forEach(b => {
            const k = String(b.dataset.key || '');
            b.checked = stored.includes(k);
          });
        } else {
          // fallback: parse current input value
          let target = null;
          const TEXT_SELECTOR = 'input[type="text"], input[type="search"], input[type="url"], input[type="tel"], input[type="email"], textarea';
          if (this.container) {
            target = this.container.querySelector(TEXT_SELECTOR);
            if (target && !isVisible(target)) target = null;
          }
          if (!target) {
            const cand = qsa(TEXT_SELECTOR);
            target = cand.find(isVisible) || null;
          }
          const cur = String((target && target.value) || '').toLowerCase().trim();
          boxes.forEach(b => {
            const tok = String(b.dataset.key || '').toLowerCase();
            b.checked = cur.includes(tok);
          });
        }
      } catch (err) {
        // sync failures are non-fatal
      }
    }

    positionMenuUnderButton(menu, button) {
      const rect = button.getBoundingClientRect();
      // Keep menu within viewport horizontally if possible
      const left = Math.max(8, rect.left);
      const top = window.scrollY + rect.bottom + 6;
      menu.style.left = `${left}px`;
      menu.style.top = `${top}px`;
    }

    onDocumentClick(e) {
      if (!this.openMenu) return;
      const target = e.target;
      // if clicked inside openMenu or the corresponding button, ignore
      const matchingButton = Array.from(this.dropdowns.values()).find(v => v.menu === this.openMenu)?.button;
      if (matchingButton && (matchingButton.contains(target) || this.openMenu.contains(target))) return;
      this.closeOpenMenu();
    }

    onWindowResize() {
      if (!this.openMenu) return;
      // reposition if open
      const owner = this.openMenu.dataset.owner;
      const entry = this.dropdowns.get(owner);
      if (entry) this.positionMenuUnderButton(entry.menu, entry.button);
    }

    closeOpenMenu() {
      if (!this.openMenu) return;
      const owner = this.openMenu.dataset.owner;
      const entry = this.dropdowns.get(owner);
      if (entry) entry.button.setAttribute('aria-expanded', 'false');
      this.openMenu.style.display = 'none';
      this.openMenu = null;
    }

    onSelectPattern(value, name) {
      // Try target in container first, then visible text-like input/textarea
      const TEXT_SELECTOR = 'input[type="text"], input[type="search"], input[type="url"], input[type="tel"], input[type="email"], textarea';
      let target = null;
      if (this.container) {
        target = this.container.querySelector(TEXT_SELECTOR);
        if (target && !isVisible(target)) target = null;
      }
      if (!target) {
        const cand = qsa(TEXT_SELECTOR);
        target = cand.find(isVisible) || null;
      }
      if (!target) return;
      try {
        setInputValueReactive(target, value || '');
        // re-append any selected quality tokens as the parenthesized group
        this.applySelectedQualityToInput();
      } catch (err) {
        console.error('dmm-tg: failed to set input value', err, {
          value,
          name
        });
      }
    }
  }

  /* ===========================
      PageManager - watches for SPA navigations / DOM changes
      =========================== */
  class PageManager {
    constructor() {
      this.buttonManager = new ButtonManager();
      this.lastUrl = location.href;
      this.mutationObserver = null;
      this.retry = 0;
      this.debouncedCheck = debounce(this.checkPage.bind(this), CONFIG.DEBOUNCE_MS);
      this.setupHistoryHooks();
      this.setupMutationObserver();
      this.checkPage(); // initial
    }

    setupHistoryHooks() {
      const push = history.pushState;
      const replace = history.replaceState;
      history.pushState = function pushState(...args) {
        push.apply(this, args);
        window.dispatchEvent(new Event('dmm:nav'));
      };
      history.replaceState = function replaceState(...args) {
        replace.apply(this, args);
        window.dispatchEvent(new Event('dmm:nav'));
      };
      window.addEventListener('popstate', () => window.dispatchEvent(new Event('dmm:nav')));
      window.addEventListener('hashchange', () => window.dispatchEvent(new Event('dmm:nav')));
      window.addEventListener('dmm:nav', () => {
        this.buttonManager.cleanup();
        this.debouncedCheck();
      });
    }

    setupMutationObserver() {
      if (this.mutationObserver) this.mutationObserver.disconnect();
      this.mutationObserver = new MutationObserver((mutations) => {
        for (const m of mutations) {
          if (m.type === 'childList' && m.addedNodes.length > 0) {
            this.debouncedCheck();
            break;
          }
        }
      });
      this.mutationObserver.observe(document.body, {
        childList: true,
        subtree: true
      });
    }

    checkPage() {
      const url = location.href;
      // Only operate on target pages
      if (!CONFIG.RELEVANT_PAGE_RX.test(url)) {
        this.buttonManager.cleanup();
        this.lastUrl = url;
        return;
      }

      // find container
      const container = qs(CONFIG.CONTAINER_SELECTOR);
      if (!container) {
        // limited retries to catch late-loading DOM (e.g. framework renders)
        if (this.retry < CONFIG.MAX_RETRIES) {
          this.retry++;
          setTimeout(() => this.debouncedCheck(), 150);
        } else {
          this.retry = 0;
        }
        return;
      }
      this.retry = 0;
      // init manager
      this.buttonManager.initialize(container);
      this.lastUrl = url;
    }
  }

  /* ===========================
      Boot
      =========================== */
  function ready(fn) {
    if (document.readyState !== 'loading') fn();
    else document.addEventListener('DOMContentLoaded', fn);
  }

  ready(() => {
    try {
      // only continue if we have patterns to show
      if (!BUTTON_DATA.length) return;
      // start page manager
      new PageManager();
    } catch (err) {
      console.error('dmm-tg boot error', err);
    }
  });
})();