YouTube Video Filter

Filter YouTube videos by views, age, and duration; fixes Infinity storage, date UTC bugs, and unknown metadata handling

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         YouTube Video Filter
// @namespace    https://github.com/quantavil
// @version      3.0.1
// @description  Filter YouTube videos by views, age, and duration; fixes Infinity storage, date UTC bugs, and unknown metadata handling
// @author       You
// @match        https://www.youtube.com/*
// @grant        GM_addStyle
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(() => {
  'use strict';

  // ---------- Config/state ----------
  const filters = {
    minViews: 0,
    maxViews: Infinity,
    minDays: 0,
    maxDays: Infinity,
    minDuration: 0,
    maxDuration: Infinity,
    enabled: false,
  };

  const STORAGE_KEY = 'ytVideoFilter:v3';
  const VIDEO_HOST_SELECTORS = [
    'ytd-rich-item-renderer',
    'ytd-video-renderer',
    'ytd-grid-video-renderer',
    'ytd-compact-video-renderer',
  ];

  // ---------- Styles ----------
  GM_addStyle(`
    #yt-filter-toggle {
      position: fixed;
      top: 50%;
      right: 0;
      transform: translateY(-50%) translateX(42px);
      z-index: 10001;
      background: #000;
      color: #fff;
      border: none;
      border-radius: 8px 0 0 8px;
      padding: 16px 10px;
      cursor: pointer;
      font: 400 20px/1 Arial, sans-serif;
      box-shadow: -2px 0 12px rgba(0,0,0,.4);
      transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), z-index 0s;
      writing-mode: vertical-rl;
      letter-spacing: 1px;
    }
    #yt-filter-toggle:hover {
      transform: translateY(-50%) translateX(0);
      background: #1a1a1a;
    }
    #yt-filter-toggle.active {
      transform: translateY(-50%) translateX(0);
      background: #1a1a1a;
      z-index: 9999;
    }
    #yt-filter-toggle::before {
      content: '⚙';
      font-size: 18px;
      display: block;
      margin-bottom: 8px;
      writing-mode: horizontal-tb;
    }
    
    #yt-filter-panel {
      position: fixed;
      top: 50%;
      right: -340px;
      transform: translateY(-50%);
      z-index: 10000;
      width: 320px;
      max-height: 85vh;
      overflow-y: auto;
      overflow-x: hidden;
      background: #0f0f0f;
      color: #fff;
      border: 1px solid #2a2a2a;
      border-right: none;
      border-radius: 12px 0 0 12px;
      box-shadow: -4px 0 24px rgba(0,0,0,.8);
      padding: 20px;
      transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
      font: 13px/1.4 Roboto, Arial, sans-serif;
    }
    #yt-filter-panel.visible {
      right: 0;
    }
    #yt-filter-panel::-webkit-scrollbar {
      width: 8px;
    }
    #yt-filter-panel::-webkit-scrollbar-track {
      background: #1a1a1a;
    }
    #yt-filter-panel::-webkit-scrollbar-thumb {
      background: #3a3a3a;
      border-radius: 4px;
    }
    #yt-filter-panel::-webkit-scrollbar-thumb:hover {
      background: #4a4a4a;
    }
    
    #yt-filter-panel h3 {
      margin: 0 0 16px;
      font: 500 18px/1.2 Roboto, Arial, sans-serif;
      display: flex;
      align-items: center;
      justify-content: space-between;
      border-bottom: 1px solid #2a2a2a;
      padding-bottom: 12px;
    }
    .ytf-close {
      cursor: pointer;
      font-size: 24px;
      width: 32px;
      height: 32px;
      display: flex;
      align-items: center;
      justify-content: center;
      border-radius: 50%;
      transition: background 0.2s;
    }
    .ytf-close:hover {
      background: #2a2a2a;
    }
    
    .ytf-group {
      margin-bottom: 16px;
    }
    .ytf-group label {
      display: block;
      margin-bottom: 8px;
      color: #aaa;
      font-size: 12px;
      text-transform: uppercase;
      letter-spacing: 0.5px;
    }
    .ytf-row {
      display: flex;
      gap: 8px;
      position: relative;
      z-index: 1;
    }
    .ytf-input-wrapper {
      flex: 1;
      position: relative;
    }
    .ytf-input {
      width: 100%;
      padding: 10px 12px;
      border-radius: 6px;
      border: 1px solid #2a2a2a;
      background: #1a1a1a;
      color: #fff;
      font-size: 13px;
      transition: all 0.2s ease;
      position: relative;
      z-index: 2;
      box-sizing: border-box;
    }
    .ytf-input:focus {
      outline: none;
      border-color: #3ea6ff;
      z-index: 3;
    }
    .ytf-input.error {
      border-color: #ff4444;
      background: rgba(255, 68, 68, 0.1);
    }
    .ytf-input::placeholder {
      color: #666;
    }
    
    .ytf-input[type="date"] {
      position: relative;
      z-index: 2;
      appearance: none;
      -webkit-appearance: none;
      -moz-appearance: none;
    }
    .ytf-input[type="date"]::-webkit-calendar-picker-indicator {
      filter: invert(1);
      cursor: pointer;
      position: relative;
      z-index: 4;
      opacity: 0.7;
      transition: opacity 0.2s ease;
    }
    .ytf-input[type="date"]::-webkit-calendar-picker-indicator:hover {
      opacity: 1;
    }
    .ytf-input[type="date"]::-webkit-datetime-edit {
      color: #fff;
    }
    .ytf-input[type="date"]::-webkit-datetime-edit-fields-wrapper {
      padding: 0;
    }
    .ytf-input[type="date"]::-webkit-inner-spin-button {
      display: none;
    }
    
    .ytf-error-msg {
      color: #ff4444;
      font-size: 11px;
      margin-top: 4px;
      display: none;
      animation: fadeIn 0.2s ease;
    }
    .ytf-error-msg.show {
      display: block;
    }
    
    @keyframes fadeIn {
      from { opacity: 0; transform: translateY(-2px); }
      to { opacity: 1; transform: translateY(0); }
    }
    
    .ytf-actions {
      display: flex;
      gap: 8px;
      margin-top: 20px;
    }
    .ytf-btn {
      flex: 1;
      padding: 11px;
      border-radius: 8px;
      border: 1px solid #2a2a2a;
      background: #1a1a1a;
      color: #fff;
      cursor: pointer;
      font-size: 13px;
      font-weight: 500;
      transition: all 0.2s;
    }
    .ytf-btn:hover:not(:disabled) {
      background: #2a2a2a;
      transform: translateY(-1px);
    }
    .ytf-btn:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
    .ytf-btn.primary {
      background: #3ea6ff;
      border: none;
      color: #000;
    }
    .ytf-btn.primary:hover:not(:disabled) {
      background: #4db8ff;
    }
    .ytf-btn.primary.active {
      background: #00c853;
    }
    .ytf-btn.primary.active:hover:not(:disabled) {
      background: #00e676;
    }
    
    .ytf-stats {
      margin-top: 16px;
      padding: 12px;
      background: #1a1a1a;
      border-radius: 6px;
      border: 1px solid #2a2a2a;
      color: #aaa;
      text-align: center;
      font-size: 12px;
    }
    .ytf-hidden {
      display: none !important;
    }
  `);

  // ---------- Utilities ----------
  const qs = (root, sel) => root.querySelector(sel);
  const qsa = (root, sel) => root.querySelectorAll(sel);
  const byText = (nodes, pred) => {
    for (const n of nodes) {
      const t = (n.textContent || '').trim();
      if (t && pred(t)) return t;
    }
    return '';
  };

  const parseNumberWithSuffix = (txt) => {
    const m = (txt || '').replace(/[, ]/g, '').match(/([\d.]+)\s*([KMB])?/i);
    if (!m) {
      const num = parseInt((txt || '').replace(/[^\d]/g, ''), 10);
      return Number.isFinite(num) ? num : 0;
    }
    let n = parseFloat(m[1]) || 0;
    const s = (m[2] || '').toUpperCase();
    if (s === 'K') n *= 1e3;
    else if (s === 'M') n *= 1e6;
    else if (s === 'B') n *= 1e9;
    return Math.floor(n);
  };

  const parseViews = (txt) => parseNumberWithSuffix((txt || '').replace(/views?/i, '').trim());
  
  const parseDaysAgo = (txt) => {
    if (!txt) return Infinity;
    const s = txt.toLowerCase().replace('streamed', '').replace('premiered', '').trim();
    const m = s.match(/(\d+)\s*(second|minute|hour|day|week|month|year)s?/);
    if (!m) return Infinity;
    const v = parseInt(m[1], 10);
    const unit = m[2];
    const mult = {
      second: 1 / (24 * 3600),
      minute: 1 / (24 * 60),
      hour: 1 / 24,
      day: 1,
      week: 7,
      month: 30,
      year: 365,
    }[unit] || 1;
    return v * mult;
  };

  // ✅ FIXED: Use NaN for unknown, floor instead of round, handle LIVE
  const parseDuration = (txt) => {
    if (!txt) return NaN;
    const t = txt.trim();
    if (/^live$/i.test(t)) return NaN; // LIVE = unknown duration
    
    const parts = t.split(':').map(x => parseInt(x, 10) || 0);
    let seconds = 0;
    if (parts.length === 3) seconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
    else if (parts.length === 2) seconds = parts[0] * 60 + parts[1];
    else seconds = parts[0] || 0;
    
    return Math.floor(seconds / 60); // floor, not round
  };

  // ✅ FIXED: Proper local date formatting (no UTC conversion)
  const toLocalISODate = (d) => {
    const y = d.getFullYear();
    const m = String(d.getMonth() + 1).padStart(2, '0');
    const day = String(d.getDate()).padStart(2, '0');
    return `${y}-${m}-${day}`;
  };

  // ✅ FIXED: Normalize to local noon to avoid timezone issues
  const daysAgoToDate = (days) => {
    if (!Number.isFinite(days) || days === Infinity) return '';
    const d = new Date();
    d.setHours(12, 0, 0, 0); // Normalize to noon
    d.setDate(d.getDate() - Math.floor(days));
    return toLocalISODate(d);
  };

  // ✅ FIXED: Force local noon on both sides to avoid DST/UTC issues
  const dateToDaysAgo = (dateStr) => {
    if (!dateStr) return Infinity;
    const d = new Date(dateStr + 'T12:00:00'); // Force local noon
    if (isNaN(d.getTime())) return Infinity;
    const now = new Date();
    now.setHours(12, 0, 0, 0); // Normalize current time
    return Math.floor((now - d) / 86400000);
  };

  const isShorts = (host) => {
    if (qs(host, 'a[href*="/shorts/"]')) return true;
    if (qs(host, '[is-shorts], [href^="/shorts/"]')) return true;
    return false;
  };

  // ✅ IMPROVED: Enhanced selectors with fallbacks
  const getVideoMeta = (host) => {
    const metaLineSpans = qsa(host, '#metadata-line span, .inline-metadata-item');
    const cmvSpans = qsa(host, '.yt-content-metadata-view-model__metadata-row span');
    const allSpans = [...metaLineSpans, ...cmvSpans];

    const viewsTxt = byText(allSpans, t => /view/i.test(t) && !/watching/i.test(t));
    const timeTxt = byText(allSpans, t => /(ago|streamed|premiered)/i.test(t));

    // Enhanced duration detection with fallbacks
    let durationTxt =
      (qs(host, 'ytd-thumbnail-overlay-time-status-renderer #text')?.textContent || '').trim() ||
      (qs(host, 'ytd-thumbnail-overlay-time-status-renderer [id="text"]')?.textContent || '').trim() ||
      (qs(host, '.yt-thumbnail-overlay-badge-view-model .yt-badge-shape__text')?.textContent || '').trim() ||
      '';

    // Fallback: Try aria-label on thumbnail
    if (!durationTxt) {
      const thumb = qs(host, 'ytd-thumbnail[aria-label], a#thumbnail[aria-label]');
      const ariaLabel = thumb?.getAttribute('aria-label') || '';
      const match = ariaLabel.match(/(\d+:\d+(?::\d+)?)/);
      if (match) durationTxt = match[1];
    }

    return {
      views: parseViews(viewsTxt),
      daysAgo: parseDaysAgo(timeTxt),
      duration: parseDuration(durationTxt),
    };
  };

  // ---------- Filtering ----------
  // ✅ FIXED: Proper handling of unknown dates and durations
  const matches = (host) => {
    if (!filters.enabled) return true;
    
    // Ignore shorts (don't filter them)
    if (isShorts(host)) return true;

    const { views, daysAgo, duration } = getVideoMeta(host);

    // ✅ NEW: Reject videos with unknown date if any date filter is active
    const dateFilterActive = (filters.minDays > 0 || filters.maxDays < Infinity);
    if (daysAgo === Infinity && dateFilterActive) return false;

    // Apply view filters
    if (views < filters.minViews || views > filters.maxViews) return false;
    
    // Apply date filters
    if (daysAgo < filters.minDays || daysAgo > filters.maxDays) return false;
    
    // ✅ FIXED: Only apply duration filter when duration is known and finite
    const durationKnown = Number.isFinite(duration) && duration >= 0;
    if (durationKnown && (duration < filters.minDuration || duration > filters.maxDuration)) return false;

    return true;
  };

  const applyToAll = () => {
    const nodes = document.querySelectorAll(VIDEO_HOST_SELECTORS.join(','));
    let total = 0, hidden = 0;
    for (const host of nodes) {
      total++;
      if (matches(host)) {
        host.classList.remove('ytf-hidden');
      } else {
        host.classList.add('ytf-hidden');
        hidden++;
      }
    }
    const stats = document.getElementById('ytf-stats');
    if (stats) {
      if (filters.enabled) {
        stats.textContent = `Showing ${total - hidden} of ${total} videos`;
      } else {
        stats.textContent = `Filter disabled`;
      }
    }
  };

  // ✅ IMPROVED: Better debouncing for rapid mutations
  let raf = 0;
  let debounceTimer = 0;
  const scheduleApply = () => {
    if (raf) cancelAnimationFrame(raf);
    clearTimeout(debounceTimer);
    
    debounceTimer = setTimeout(() => {
      raf = requestAnimationFrame(() => {
        applyToAll();
        raf = 0;
      });
    }, 100); // Wait 100ms for rapid mutations
  };

  // ---------- Storage ----------
  // ✅ FIXED: Properly handle Infinity serialization
  const persist = () => {
    const state = {
      minViews: filters.minViews || 0,
      maxViews: Number.isFinite(filters.maxViews) ? filters.maxViews : null,
      minDays: filters.minDays || 0,
      maxDays: Number.isFinite(filters.maxDays) ? filters.maxDays : null,
      minDuration: filters.minDuration || 0,
      maxDuration: Number.isFinite(filters.maxDuration) ? filters.maxDuration : null,
      enabled: !!filters.enabled,
    };
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  };
  
  // ✅ FIXED: Coerce null back to Infinity on load
  const load = () => {
    try {
      const d = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
      filters.minViews = d.minViews ?? 0;
      filters.maxViews = d.maxViews ?? Infinity;
      filters.minDays = d.minDays ?? 0;
      filters.maxDays = d.maxDays ?? Infinity;
      filters.minDuration = d.minDuration ?? 0;
      filters.maxDuration = d.maxDuration ?? Infinity;
      filters.enabled = !!d.enabled;
    } catch {
      // Keep defaults on parse error
    }
  };

  // ---------- Validation ----------
  const validateNumber = (value, allowSuffix = false) => {
    if (value === '' || value == null) return { valid: true, value: null };
    
    const cleaned = value.replace(/[,\s]/g, '');
    
    if (allowSuffix) {
      if (!/^[\d.]+[KMB]?$/i.test(cleaned)) {
        return { valid: false, error: 'Use format: 1.5K, 10M, or 1,234' };
      }
      return { valid: true, value: parseNumberWithSuffix(value) };
    }
    
    const num = parseInt(cleaned, 10);
    if (!Number.isFinite(num) || num < 0) {
      return { valid: false, error: 'Must be a positive number' };
    }
    
    return { valid: true, value: num };
  };

  const validateDate = (value) => {
    if (value === '' || value == null) return { valid: true, value: null };
    
    const d = new Date(value + 'T00:00:00');
    if (isNaN(d.getTime())) {
      return { valid: false, error: 'Invalid date format' };
    }
    
    const now = new Date();
    now.setHours(0, 0, 0, 0);
    const inputDate = new Date(value + 'T00:00:00');
    
    if (inputDate > now) {
      return { valid: false, error: 'Date cannot be in the future' };
    }
    
    return { valid: true, value: value };
  };

  const showError = (input, message) => {
    input.classList.add('error');
    const wrapper = input.closest('.ytf-input-wrapper');
    let errorMsg = wrapper.querySelector('.ytf-error-msg');
    if (!errorMsg) {
      errorMsg = document.createElement('div');
      errorMsg.className = 'ytf-error-msg';
      wrapper.appendChild(errorMsg);
    }
    errorMsg.textContent = message;
    errorMsg.classList.add('show');
  };

  const clearError = (input) => {
    input.classList.remove('error');
    const wrapper = input.closest('.ytf-input-wrapper');
    const errorMsg = wrapper.querySelector('.ytf-error-msg');
    if (errorMsg) {
      errorMsg.classList.remove('show');
    }
  };

  const validateAllInputs = (minViews, maxViews, minDate, maxDate, minDur, maxDur) => {
    const minViewsVal = validateNumber(minViews.value.trim(), true);
    const maxViewsVal = validateNumber(maxViews.value.trim(), true);
    const minDateVal = validateDate(minDate.value.trim());
    const maxDateVal = validateDate(maxDate.value.trim());
    const minDurVal = validateNumber(minDur.value.trim(), false);
    const maxDurVal = validateNumber(maxDur.value.trim(), false);

    [minViews, maxViews, minDate, maxDate, minDur, maxDur].forEach(clearError);

    let hasError = false;

    if (!minViewsVal.valid) { showError(minViews, minViewsVal.error); hasError = true; }
    if (!maxViewsVal.valid) { showError(maxViews, maxViewsVal.error); hasError = true; }
    if (!minDateVal.valid) { showError(minDate, minDateVal.error); hasError = true; }
    if (!maxDateVal.valid) { showError(maxDate, maxDateVal.error); hasError = true; }
    if (!minDurVal.valid) { showError(minDur, minDurVal.error); hasError = true; }
    if (!maxDurVal.valid) { showError(maxDur, maxDurVal.error); hasError = true; }

    // Date range validation
    if (minDateVal.valid && maxDateVal.valid && minDateVal.value && maxDateVal.value) {
      const fromDate = new Date(minDateVal.value);
      const toDate = new Date(maxDateVal.value);
      if (fromDate > toDate) {
        showError(minDate, 'From date must be before To date');
        hasError = true;
      }
    }

    return {
      valid: !hasError,
      minViews: minViewsVal.value,
      maxViews: maxViewsVal.value,
      minDate: minDateVal.value,
      maxDate: maxDateVal.value,
      minDur: minDurVal.value,
      maxDur: maxDurVal.value,
    };
  };

  // ---------- UI ----------
  const createUI = () => {
    const oldBtn = document.getElementById('yt-filter-toggle');
    const oldPanel = document.getElementById('yt-filter-panel');
    if (oldBtn) oldBtn.remove();
    if (oldPanel) oldPanel.remove();

    // Toggle button
    const btn = document.createElement('button');
    btn.id = 'yt-filter-toggle';
    btn.textContent = 'FILTER';
    document.body.appendChild(btn);

    // Panel
    const panel = document.createElement('div');
    panel.id = 'yt-filter-panel';

    const h = document.createElement('h3');
    h.textContent = 'Video Filters';
    const close = document.createElement('span');
    close.className = 'ytf-close';
    close.title = 'Close';
    close.textContent = '×';
    close.addEventListener('click', () => {
      panel.classList.remove('visible');
      btn.classList.remove('active');
    });
    h.appendChild(close);
    panel.appendChild(h);

    btn.addEventListener('click', () => {
      const isVisible = panel.classList.toggle('visible');
      btn.classList.toggle('active', isVisible);
    });

    const group = (labelTxt, inputs) => {
      const g = document.createElement('div');
      g.className = 'ytf-group';
      const l = document.createElement('label');
      l.textContent = labelTxt;
      const row = document.createElement('div');
      row.className = 'ytf-row';
      
      inputs.forEach(input => {
        const wrapper = document.createElement('div');
        wrapper.className = 'ytf-input-wrapper';
        wrapper.appendChild(input);
        row.appendChild(wrapper);
      });
      
      g.appendChild(l);
      g.appendChild(row);
      return g;
    };

    const iText = (id, ph) => {
      const i = document.createElement('input');
      i.className = 'ytf-input';
      i.id = id;
      i.placeholder = ph;
      i.type = 'text';
      return i;
    };
    
    const iDate = (id, ph) => {
      const i = document.createElement('input');
      i.className = 'ytf-input';
      i.id = id;
      i.placeholder = ph;
      i.type = 'date';
      return i;
    };
    
    const iNum = (id, ph) => {
      const i = document.createElement('input');
      i.className = 'ytf-input';
      i.id = id;
      i.placeholder = ph;
      i.type = 'number';
      i.min = '0';
      i.step = '1';
      return i;
    };

    const minViews = iText('minViews', 'Min (e.g., 10K)');
    const maxViews = iText('maxViews', 'Max (e.g., 10M)');
    const minDate = iDate('minDate', 'From');
    const maxDate = iDate('maxDate', 'To');
    const minDur  = iNum('minDuration', 'Min (mins)');
    const maxDur  = iNum('maxDuration', 'Max (mins)');

    [minViews, maxViews, minDate, maxDate, minDur, maxDur].forEach(input => {
      input.addEventListener('input', () => clearError(input));
    });

    // Hydrate inputs from loaded state
    const setVal = (el, v) => { el.value = (v === Infinity || v === 0) ? '' : String(v); };
    setVal(minViews, filters.minViews);
    setVal(maxViews, filters.maxViews);
    
    // Date mapping note: "From" (older) = maxDays, "To" (newer) = minDays
    // (because older dates have larger daysAgo values)
    minDate.value = (filters.maxDays !== Infinity) ? daysAgoToDate(filters.maxDays) : '';
    maxDate.value = (filters.minDays !== 0) ? daysAgoToDate(filters.minDays) : '';
    
    setVal(minDur,  filters.minDuration);
    setVal(maxDur,  filters.maxDuration);

    panel.appendChild(group('Views', [minViews, maxViews]));
    panel.appendChild(group('Date Range', [minDate, maxDate]));
    panel.appendChild(group('Duration (minutes)', [minDur, maxDur]));

    const actions = document.createElement('div');
    actions.className = 'ytf-actions';

    const apply = document.createElement('button');
    apply.className = 'ytf-btn primary';
    apply.id = 'ytf-apply';
    apply.textContent = filters.enabled ? 'Disable Filter' : 'Apply Filter';
    if (filters.enabled) apply.classList.add('active');
    
    apply.addEventListener('click', () => {
      if (filters.enabled) {
        // Disable mode
        filters.enabled = false;
        apply.textContent = 'Apply Filter';
        apply.classList.remove('active');
        persist();
        scheduleApply();
      } else {
        // Apply mode
        const validation = validateAllInputs(minViews, maxViews, minDate, maxDate, minDur, maxDur);
        if (!validation.valid) return;
        
        filters.minViews = validation.minViews ?? 0;
        filters.maxViews = validation.maxViews ?? Infinity;
        
        // Date range works inversely:
        // - "From" (older date) = larger daysAgo = maxDays
        // - "To" (newer date) = smaller daysAgo = minDays
        filters.maxDays = validation.minDate ? dateToDaysAgo(validation.minDate) : Infinity;
        filters.minDays = validation.maxDate ? dateToDaysAgo(validation.maxDate) : 0;
        
        filters.minDuration = validation.minDur ?? 0;
        filters.maxDuration = validation.maxDur ?? Infinity;
        
        filters.enabled = true;
        apply.textContent = 'Disable Filter';
        apply.classList.add('active');
        persist();
        scheduleApply();
      }
    });

    const reset = document.createElement('button');
    reset.className = 'ytf-btn';
    reset.textContent = 'Reset';
    reset.addEventListener('click', () => {
      [minViews, maxViews, minDate, maxDate, minDur, maxDur].forEach(i => {
        i.value = '';
        clearError(i);
      });
      
      filters.minViews = 0;
      filters.maxViews = Infinity;
      filters.minDays = 0;
      filters.maxDays = Infinity;
      filters.minDuration = 0;
      filters.maxDuration = Infinity;
      filters.enabled = false;
      
      apply.textContent = 'Apply Filter';
      apply.classList.remove('active');
      
      persist();
      scheduleApply();
    });

    actions.appendChild(apply);
    actions.appendChild(reset);
    panel.appendChild(actions);

    const stats = document.createElement('div');
    stats.className = 'ytf-stats';
    stats.id = 'ytf-stats';
    stats.textContent = filters.enabled ? 'Applying…' : 'Filter disabled';
    panel.appendChild(stats);

    document.body.appendChild(panel);
  };

  // ---------- Observers & navigation ----------
  const attachObservers = () => {
    const root = document.querySelector('ytd-app') || document.body;
    if (!root) return;

    const mo = new MutationObserver(() => scheduleApply());
    mo.observe(root, { childList: true, subtree: true });

    window.addEventListener('yt-navigate-start', () => scheduleApply(), true);
    window.addEventListener('yt-navigate-finish', () => {
      requestAnimationFrame(() => scheduleApply());
    }, true);

    window.addEventListener('load', () => scheduleApply(), true);
    window.addEventListener('popstate', () => scheduleApply(), true);
    window.addEventListener('hashchange', () => scheduleApply(), true);
  };

  // ---------- Init ----------
  const init = () => {
    load();
    createUI();
    attachObservers();
    setTimeout(scheduleApply, 600);
  };

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }
})();