YouTube Sort by Upload Date

Restores "Sort by Upload Date" functionality to YouTube search using InnerTube API + optional YouTube Data API v3 fallback

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 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         YouTube Sort by Upload Date
// @namespace    https://greatest.deepsurf.us/en/users/10118-drhouse
// @version      1.2.0
// @description  Restores "Sort by Upload Date" functionality to YouTube search using InnerTube API + optional YouTube Data API v3 fallback
// @match        https://www.youtube.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// @author       drhouse
// @license      CC-BY-NC-SA-4.0
// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// ==/UserScript==

(function () {
  'use strict';

  // ─── CONFIG ────────────────────────────────────────────────────────
  const SETTINGS_KEY = 'yt_sort_date_settings';
  const defaults = { apiKey: '', engine: 'innertube' }; // engine: 'innertube' | 'dataapi'

  function getSettings() {
    try { return Object.assign({}, defaults, JSON.parse(GM_getValue(SETTINGS_KEY, '{}'))); }
    catch { return { ...defaults }; }
  }
  function saveSettings(s) { GM_setValue(SETTINGS_KEY, JSON.stringify(s)); }

  // ─── ELEMENT WAITING HELPERS ──────────────────────────────────────
  /**
   * Waits for an element matching `selector` to appear in the DOM.
   * Uses MutationObserver + polling fallback for maximum reliability.
   * Returns a Promise that resolves with the element.
   */
  function waitForElement(selector, timeout = 15000) {
    return new Promise((resolve, reject) => {
      const existing = document.querySelector(selector);
      if (existing) return resolve(existing);

      let resolved = false;
      const timer = setTimeout(() => {
        if (!resolved) {
          resolved = true;
          observer.disconnect();
          clearInterval(poll);
          reject(new Error(`waitForElement("${selector}") timed out after ${timeout}ms`));
        }
      }, timeout);

      const poll = setInterval(() => {
        const el = document.querySelector(selector);
        if (el && !resolved) {
          resolved = true;
          observer.disconnect();
          clearInterval(poll);
          clearTimeout(timer);
          resolve(el);
        }
      }, 300);

      const observer = new MutationObserver(() => {
        const el = document.querySelector(selector);
        if (el && !resolved) {
          resolved = true;
          observer.disconnect();
          clearInterval(poll);
          clearTimeout(timer);
          resolve(el);
        }
      });

      const root = document.querySelector('ytd-app') || document.body;
      observer.observe(root, { childList: true, subtree: true });
    });
  }

  /**
   * Waits for YouTube's custom elements to be defined (registered with the browser).
   */
  async function waitForYouTubeElements() {
    const elements = ['ytd-search', 'ytd-search-header-renderer'];
    await Promise.all(
      elements
        .filter(tag => !customElements.get(tag))
        .map(tag => customElements.whenDefined(tag))
    );
  }

  // ─── DATE PARSING ─────────────────────────────────────────────────
  const TIME_UNITS = {
    second: 1000, seconds: 1000,
    minute: 60000, minutes: 60000,
    hour: 3600000, hours: 3600000,
    day: 86400000, days: 86400000,
    week: 604800000, weeks: 604800000,
    month: 2592000000, months: 2592000000,
    year: 31536000000, years: 31536000000,
  };

  function relativeToTimestamp(text) {
    if (!text) return 0;
    const t = text.toLowerCase().trim();
    const cleaned = t.replace(/^streamed\s+/i, '');
    const match = cleaned.match(/(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago/);
    if (match) {
      const num = parseInt(match[1], 10);
      const unit = match[2];
      const ms = TIME_UNITS[unit] || 0;
      return Date.now() - num * ms;
    }
    const parsed = Date.parse(text);
    if (!isNaN(parsed)) return parsed;
    return 0;
  }

  // ─── INNERTUBE ENGINE ─────────────────────────────────────────────
  async function searchInnerTube(query) {
    const apiKey = window.ytcfg?.get?.('INNERTUBE_API_KEY') || 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
    const context = window.ytcfg?.get?.('INNERTUBE_CONTEXT') || {
      client: { clientName: 'WEB', clientVersion: '2.20260301.00.00', hl: 'en', gl: 'US' }
    };
    const body = {
      query,
      context,
      params: 'CAI%3D' // protobuf: sort by upload date
    };
    const res = await fetch(`/youtubei/v1/search?key=${apiKey}&prettyPrint=false`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    if (!res.ok) throw new Error(`InnerTube search failed: ${res.status}`);
    const data = await res.json();
    return parseInnerTubeResults(data);
  }

  function parseInnerTubeResults(data) {
    const results = [];
    try {
      const contents =
        data?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents || [];
      for (const section of contents) {
        const items = section?.itemSectionRenderer?.contents || [];
        for (const item of items) {
          const vid = item?.videoRenderer;
          if (!vid) continue;
          const publishedText = vid?.publishedTimeText?.simpleText || vid?.publishedTimeText?.runs?.[0]?.text || '';
          results.push({
            videoId: vid.videoId,
            title: vid?.title?.runs?.map(r => r.text).join('') || '',
            channelName: vid?.ownerText?.runs?.[0]?.text || '',
            channelUrl: vid?.ownerText?.runs?.[0]?.navigationEndpoint?.commandMetadata?.webCommandMetadata?.url || '',
            thumbnail: vid?.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
            viewCount: vid?.viewCountText?.simpleText || vid?.viewCountText?.runs?.map(r => r.text).join('') || '',
            publishedText,
            publishedTimestamp: relativeToTimestamp(publishedText),
            duration: vid?.lengthText?.simpleText || '',
            description: vid?.detailedMetadataSnippets?.[0]?.snippetText?.runs?.map(r => r.text).join('') || '',
          });
        }
      }
    } catch (e) {
      console.error('[YT-SortDate] Error parsing InnerTube results:', e);
    }
    return results;
  }

  // ─── DATA API V3 ENGINE ───────────────────────────────────────────
  async function searchDataAPI(query, apiKey) {
    const params = new URLSearchParams({
      part: 'snippet',
      q: query,
      order: 'date',
      type: 'video',
      maxResults: '25',
      key: apiKey,
    });
    const res = await fetch(`https://www.googleapis.com/youtube/v3/search?${params}`);
    if (!res.ok) {
      const err = await res.json().catch(() => ({}));
      throw new Error(err?.error?.message || `Data API error: ${res.status}`);
    }
    const data = await res.json();
    return data.items.map(item => ({
      videoId: item.id.videoId,
      title: item.snippet.title,
      channelName: item.snippet.channelTitle,
      channelUrl: `/channel/${item.snippet.channelId}`,
      thumbnail: item.snippet.thumbnails?.high?.url || item.snippet.thumbnails?.medium?.url || '',
      viewCount: '',
      publishedText: formatRelativeDate(new Date(item.snippet.publishedAt)),
      publishedTimestamp: new Date(item.snippet.publishedAt).getTime(),
      duration: '',
      description: item.snippet.description || '',
    }));
  }

  function formatRelativeDate(date) {
    const diff = Date.now() - date.getTime();
    if (diff < 3600000) return `${Math.floor(diff / 60000)} minutes ago`;
    if (diff < 86400000) return `${Math.floor(diff / 3600000)} hours ago`;
    if (diff < 604800000) return `${Math.floor(diff / 86400000)} days ago`;
    if (diff < 2592000000) return `${Math.floor(diff / 604800000)} weeks ago`;
    if (diff < 31536000000) return `${Math.floor(diff / 2592000000)} months ago`;
    return `${Math.floor(diff / 31536000000)} years ago`;
  }

  // ─── UNIFIED SEARCH ───────────────────────────────────────────────
  async function performDateSearch(query) {
    const settings = getSettings();

    if (settings.engine === 'innertube' || !settings.apiKey) {
      try {
        let results = await searchInnerTube(query);
        results.sort((a, b) => b.publishedTimestamp - a.publishedTimestamp);
        return { results, engine: 'innertube' };
      } catch (e) {
        console.warn('[YT-SortDate] InnerTube failed, trying Data API fallback:', e);
        if (!settings.apiKey) throw e;
      }
    }

    if (settings.apiKey) {
      const results = await searchDataAPI(query, settings.apiKey);
      return { results, engine: 'dataapi' };
    }

    throw new Error('No search engine available');
  }

  // ─── RESULT RENDERING ─────────────────────────────────────────────
  function renderResults(results, container) {
    container.innerHTML = '';
    if (results.length === 0) {
      container.innerHTML = `<div style="padding:24px;color:var(--yt-spec-text-secondary, #aaa);font-size:14px;">No results found.</div>`;
      return;
    }
    for (const r of results) {
      const el = document.createElement('div');
      el.className = 'yt-sort-date-result';
      el.innerHTML = `
        <div class="yt-sort-date-result-link">
          <a href="/watch?v=${r.videoId}" class="yt-sort-date-thumb-wrap">
            <img src="${r.thumbnail}" alt="" loading="lazy" />
            ${r.duration ? `<span class="yt-sort-date-duration">${r.duration}</span>` : ''}
          </a>
          <div class="yt-sort-date-meta">
            <a href="/watch?v=${r.videoId}" class="yt-sort-date-title-link">
              <h3 class="yt-sort-date-title">${escapeHtml(r.title)}</h3>
            </a>
            <div class="yt-sort-date-info">
              ${r.viewCount ? `<span>${escapeHtml(r.viewCount)}</span>` : ''}
              ${r.viewCount && r.publishedText ? ' \u00b7 ' : ''}
              ${r.publishedText ? `<span>${escapeHtml(r.publishedText)}</span>` : ''}
            </div>
            <div class="yt-sort-date-channel">
              <a href="${r.channelUrl}" class="yt-sort-date-channel-link">${escapeHtml(r.channelName)}</a>
            </div>
            ${r.description ? `<div class="yt-sort-date-desc">${escapeHtml(r.description)}</div>` : ''}
          </div>
        </div>
      `;
      container.appendChild(el);
    }
  }

  function escapeHtml(s) {
    const d = document.createElement('div');
    d.textContent = s;
    return d.innerHTML;
  }

  // ─── STYLES ────────────────────────────────────────────────────────
  function injectStyles() {
    if (document.getElementById('yt-sort-date-styles')) return;
    const style = document.createElement('style');
    style.id = 'yt-sort-date-styles';
    style.textContent = `
      /* Sort button */
      .yt-sort-date-btn {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        padding: 8px 16px;
        margin-left: 8px;
        border: 1px solid var(--yt-spec-10-percent-layer, #3f3f3f);
        border-radius: 8px;
        background: transparent;
        color: var(--yt-spec-text-primary, #fff);
        font-family: "Roboto", "Arial", sans-serif;
        font-size: 14px;
        font-weight: 500;
        cursor: pointer;
        transition: background 0.15s, border-color 0.15s;
        white-space: nowrap;
      }
      .yt-sort-date-btn:hover {
        background: var(--yt-spec-badge-chip-background, rgba(255,255,255,0.1));
      }
      .yt-sort-date-btn.active {
        background: var(--yt-spec-text-primary, #fff);
        color: var(--yt-spec-static-brand-background, #0f0f0f);
        border-color: transparent;
      }
      .yt-sort-date-btn svg {
        width: 18px;
        height: 18px;
        fill: currentColor;
      }

      /* Settings button */
      .yt-sort-date-settings-btn {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 36px;
        height: 36px;
        margin-left: 4px;
        border: none;
        border-radius: 50%;
        background: transparent;
        color: var(--yt-spec-text-secondary, #aaa);
        cursor: pointer;
        transition: background 0.15s;
      }
      .yt-sort-date-settings-btn:hover {
        background: var(--yt-spec-badge-chip-background, rgba(255,255,255,0.1));
      }
      .yt-sort-date-settings-btn svg {
        width: 20px;
        height: 20px;
        fill: currentColor;
      }

      /* Settings panel */
      .yt-sort-date-settings-panel {
        display: none;
        position: absolute;
        top: 100%;
        right: 0;
        margin-top: 8px;
        padding: 16px;
        background: var(--yt-spec-base-background, #212121);
        border: 1px solid var(--yt-spec-10-percent-layer, #3f3f3f);
        border-radius: 12px;
        box-shadow: 0 4px 32px rgba(0,0,0,0.4);
        z-index: 9999;
        min-width: 320px;
        font-family: "Roboto", "Arial", sans-serif;
      }
      .yt-sort-date-settings-panel.open { display: block; }
      .yt-sort-date-settings-panel h4 {
        margin: 0 0 12px;
        color: var(--yt-spec-text-primary, #fff);
        font-size: 14px;
        font-weight: 500;
      }
      .yt-sort-date-settings-panel label {
        display: block;
        margin-bottom: 6px;
        color: var(--yt-spec-text-secondary, #aaa);
        font-size: 12px;
      }
      .yt-sort-date-settings-panel input[type="text"] {
        width: 100%;
        padding: 8px 12px;
        background: var(--yt-spec-additive-background, #181818);
        border: 1px solid var(--yt-spec-10-percent-layer, #3f3f3f);
        border-radius: 6px;
        color: var(--yt-spec-text-primary, #fff);
        font-size: 13px;
        outline: none;
        box-sizing: border-box;
      }
      .yt-sort-date-settings-panel input[type="text"]:focus {
        border-color: #3ea6ff;
      }
      .yt-sort-date-settings-panel select {
        width: 100%;
        padding: 8px 12px;
        background: var(--yt-spec-additive-background, #181818);
        border: 1px solid var(--yt-spec-10-percent-layer, #3f3f3f);
        border-radius: 6px;
        color: var(--yt-spec-text-primary, #fff);
        font-size: 13px;
        outline: none;
        box-sizing: border-box;
        margin-bottom: 12px;
      }
      .yt-sort-date-settings-panel .yt-sort-date-save-btn {
        display: inline-flex;
        padding: 8px 20px;
        margin-top: 12px;
        background: #3ea6ff;
        color: #0f0f0f;
        border: none;
        border-radius: 18px;
        font-size: 13px;
        font-weight: 500;
        cursor: pointer;
        transition: opacity 0.15s;
      }
      .yt-sort-date-settings-panel .yt-sort-date-save-btn:hover { opacity: 0.85; }
      .yt-sort-date-settings-panel .yt-sort-date-note {
        margin-top: 8px;
        font-size: 11px;
        color: var(--yt-spec-text-secondary, #aaa);
        line-height: 1.4;
      }

      /* Results container */
      .yt-sort-date-results-container { width: 100%; }
      .yt-sort-date-result { margin-bottom: 16px; }
      .yt-sort-date-result-link {
        display: flex;
        flex-direction: row;
        gap: 16px;
        text-decoration: none;
        color: inherit;
      }
      .yt-sort-date-thumb-wrap {
        position: relative;
        flex-shrink: 0;
        width: 360px;
        aspect-ratio: 16/9;
        border-radius: 8px;
        overflow: hidden;
        background: #000;
        display: block;
      }
      .yt-sort-date-thumb-wrap img {
        width: 100%;
        height: 100%;
        object-fit: cover;
      }
      .yt-sort-date-duration {
        position: absolute;
        bottom: 4px;
        right: 4px;
        padding: 2px 6px;
        background: rgba(0,0,0,0.8);
        color: #fff;
        font-size: 12px;
        font-weight: 500;
        border-radius: 4px;
        font-family: "Roboto", "Arial", sans-serif;
      }
      .yt-sort-date-meta {
        flex: 1;
        min-width: 0;
        padding-top: 0;
        display: flex;
        flex-direction: column;
        gap: 4px;
      }
      .yt-sort-date-title-link { text-decoration: none; color: inherit; }
      .yt-sort-date-title {
        margin: 0;
        font-size: 18px;
        font-weight: 400;
        color: var(--yt-spec-text-primary, #fff);
        line-height: 1.4;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        overflow: hidden;
      }
      .yt-sort-date-title-link:hover .yt-sort-date-title {
        color: var(--yt-spec-text-primary, #fff);
      }
      .yt-sort-date-info {
        font-size: 12px;
        color: var(--yt-spec-text-secondary, #aaa);
        line-height: 1.4;
      }
      .yt-sort-date-channel {
        font-size: 12px;
        color: var(--yt-spec-text-secondary, #aaa);
        display: flex;
        align-items: center;
        gap: 8px;
      }
      .yt-sort-date-channel-link {
        color: var(--yt-spec-text-secondary, #aaa);
        text-decoration: none;
      }
      .yt-sort-date-channel-link:hover {
        color: var(--yt-spec-text-primary, #fff);
      }
      .yt-sort-date-desc {
        font-size: 12px;
        color: var(--yt-spec-text-secondary, #aaa);
        line-height: 1.4;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        overflow: hidden;
        margin-top: 4px;
      }

      /* Loading / error states */
      .yt-sort-date-loading {
        display: flex;
        align-items: center;
        gap: 12px;
        padding: 24px;
        color: var(--yt-spec-text-secondary, #aaa);
        font-size: 14px;
      }
      .yt-sort-date-spinner {
        width: 24px;
        height: 24px;
        border: 3px solid var(--yt-spec-10-percent-layer, #3f3f3f);
        border-top-color: #3ea6ff;
        border-radius: 50%;
        animation: yt-sort-spin 0.8s linear infinite;
      }
      @keyframes yt-sort-spin { to { transform: rotate(360deg); } }
      .yt-sort-date-error {
        padding: 16px 24px;
        color: #ff4444;
        font-size: 13px;
        background: rgba(255,68,68,0.08);
        border-radius: 8px;
        margin: 8px 0;
      }
      .yt-sort-date-engine-badge {
        display: inline-block;
        padding: 2px 8px;
        margin-left: 8px;
        font-size: 11px;
        border-radius: 4px;
        background: rgba(62,166,255,0.15);
        color: #3ea6ff;
        font-weight: 500;
        vertical-align: middle;
      }

      /* Wrapper for button group */
      .yt-sort-date-wrapper {
        display: inline-flex;
        align-items: center;
        position: relative;
      }

      /* Responsive */
      @media (max-width: 800px) {
        .yt-sort-date-thumb-wrap { width: 180px; }
        .yt-sort-date-title { font-size: 14px; }
      }
    `;
    document.head.appendChild(style);
  }

  // ─── SVG ICONS ─────────────────────────────────────────────────────
  const ICON_SORT = `<svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>`;
  const ICON_GEAR = `<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1 1 12 8.4a3.6 3.6 0 0 1 0 7.2z"/></svg>`;

  // ─── STATE ─────────────────────────────────────────────────────────
  let isDateSortActive = false;
  let originalResultsHTML = '';
  let currentQuery = '';
  let lastInjectedUrl = '';

  // ─── CORE UI ───────────────────────────────────────────────────────
  function getSearchQuery() {
    const params = new URLSearchParams(window.location.search);
    return params.get('search_query') || '';
  }

  function isSearchPage() {
    return window.location.pathname === '/results';
  }

  function getResultsContainer() {
    // All selectors scoped under ytd-search to avoid matching hidden containers
    // from other pages that YouTube keeps in the DOM during SPA navigation
    return document.querySelector('ytd-search ytd-section-list-renderer[page-subtype="search"]')
        || document.querySelector('ytd-search ytd-section-list-renderer > #contents')
        || document.querySelector('ytd-search #contents.ytd-item-section-renderer')
        || document.querySelector('ytd-search #contents');
  }

  /**
   * Finds the best parent element to inject the sort button into.
   * Tries multiple selectors to handle different YouTube layouts/A/B tests.
   */
  function findInjectionTarget() {
    // Primary: the search header renderer (contains filter chips)
    const searchHeader = document.querySelector('ytd-search-header-renderer');
    if (searchHeader) return { target: searchHeader, mode: 'append' };

    // Secondary: the filter menu area
    const filterMenu = document.querySelector('#filter-menu');
    if (filterMenu) return { target: filterMenu, mode: 'append' };

    // Tertiary: the header div inside ytd-search
    const header = document.querySelector('ytd-search #header');
    if (header) return { target: header, mode: 'append' };

    // Quaternary: the search sub-menu renderer
    const subMenu = document.querySelector('ytd-search-sub-menu-renderer');
    if (subMenu) return { target: subMenu, mode: 'append' };

    // Last resort: prepend to ytd-search itself
    const ytdSearch = document.querySelector('ytd-search');
    if (ytdSearch) return { target: ytdSearch, mode: 'prepend' };

    return null;
  }

  function injectUI() {
    if (!isSearchPage()) return false;
    if (document.querySelector('.yt-sort-date-wrapper')) return true; // already injected

    const injection = findInjectionTarget();
    if (!injection) {
      console.log('[YT-SortDate] injectUI: no injection target found yet');
      return false;
    }

    console.log('[YT-SortDate] injectUI: injecting into', injection.target.tagName, 'mode:', injection.mode);

    const wrapper = document.createElement('div');
    wrapper.className = 'yt-sort-date-wrapper';

    // Sort button
    const btn = document.createElement('button');
    btn.className = 'yt-sort-date-btn';
    btn.innerHTML = `${ICON_SORT} Sort by Date`;
    btn.title = 'Sort search results by upload date (newest first)';
    btn.addEventListener('click', toggleDateSort);

    // Settings button
    const gearBtn = document.createElement('button');
    gearBtn.className = 'yt-sort-date-settings-btn';
    gearBtn.innerHTML = ICON_GEAR;
    gearBtn.title = 'Settings';
    gearBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      const panel = wrapper.querySelector('.yt-sort-date-settings-panel');
      panel?.classList.toggle('open');
    });

    // Settings panel
    const panel = document.createElement('div');
    panel.className = 'yt-sort-date-settings-panel';
    const settings = getSettings();
    panel.innerHTML = `
      <h4>Sort by Date — Settings</h4>
      <label for="yt-sort-engine">Search Engine</label>
      <select id="yt-sort-engine">
        <option value="innertube" ${settings.engine === 'innertube' ? 'selected' : ''}>InnerTube (no setup needed)</option>
        <option value="dataapi" ${settings.engine === 'dataapi' ? 'selected' : ''}>YouTube Data API v3 (needs key)</option>
      </select>
      <label for="yt-sort-apikey">YouTube Data API v3 Key (optional)</label>
      <input type="text" id="yt-sort-apikey" placeholder="AIzaSy..." value="${escapeHtml(settings.apiKey)}" />
      <div class="yt-sort-date-note">
        Free key from <a href="https://console.cloud.google.com" target="_blank" style="color:#3ea6ff;">Google Cloud Console</a>.
        Enable "YouTube Data API v3" → Create credentials → API Key. ~100 searches/day free.
      </div>
      <button class="yt-sort-date-save-btn">Save</button>
    `;
    panel.querySelector('.yt-sort-date-save-btn').addEventListener('click', () => {
      const apiKey = panel.querySelector('#yt-sort-apikey').value.trim();
      const engine = panel.querySelector('#yt-sort-engine').value;
      saveSettings({ apiKey, engine });
      panel.classList.remove('open');
    });

    document.addEventListener('click', (e) => {
      if (!wrapper.contains(e.target)) {
        panel.classList.remove('open');
      }
    });

    wrapper.appendChild(btn);
    wrapper.appendChild(gearBtn);
    wrapper.appendChild(panel);

    if (injection.mode === 'prepend') {
      injection.target.prepend(wrapper);
    } else {
      injection.target.appendChild(wrapper);
    }

    lastInjectedUrl = window.location.href;
    console.log('[YT-SortDate] injectUI: button injected successfully');
    return true;
  }

  async function toggleDateSort() {
    const btn = document.querySelector('.yt-sort-date-btn');
    if (!btn) return;

    const container = getResultsContainer();
    if (!container) return;

    if (isDateSortActive) {
      isDateSortActive = false;
      btn.classList.remove('active');
      if (originalResultsHTML) {
        container.innerHTML = originalResultsHTML;
      }
      const custom = document.querySelector('.yt-sort-date-results-container');
      custom?.remove();
      return;
    }

    isDateSortActive = true;
    btn.classList.add('active');
    currentQuery = getSearchQuery();
    if (!currentQuery) return;

    originalResultsHTML = container.innerHTML;

    container.innerHTML = `
      <div class="yt-sort-date-loading">
        <div class="yt-sort-date-spinner"></div>
        Sorting by upload date...
      </div>
    `;

    try {
      const { results, engine } = await performDateSearch(currentQuery);

      const resultsDiv = document.createElement('div');
      resultsDiv.className = 'yt-sort-date-results-container';

      const badge = document.createElement('div');
      badge.style.cssText = 'padding: 8px 0 4px; font-size: 13px; color: var(--yt-spec-text-secondary, #aaa);';
      badge.innerHTML = `Sorted by upload date (newest first) <span class="yt-sort-date-engine-badge">${engine === 'dataapi' ? 'Data API' : 'InnerTube'}</span> — ${results.length} results`;
      resultsDiv.appendChild(badge);

      renderResults(results, resultsDiv);

      container.innerHTML = '';
      container.appendChild(resultsDiv);
    } catch (err) {
      console.error('[YT-SortDate] Search error:', err);
      container.innerHTML = `
        <div class="yt-sort-date-error">
          <strong>Sort by Date error:</strong> ${escapeHtml(err.message)}<br>
          <span style="font-size:11px;opacity:0.7;">Try configuring a YouTube Data API v3 key in settings (gear icon) as a fallback.</span>
        </div>
        ${originalResultsHTML}
      `;
      isDateSortActive = false;
      btn.classList.remove('active');
    }
  }

  // ─── SPA NAVIGATION HANDLER ───────────────────────────────────────
  function onNavigate() {
    // Reset state on navigation
    isDateSortActive = false;
    originalResultsHTML = '';

    // Remove old UI (it may belong to a stale DOM subtree)
    document.querySelectorAll('.yt-sort-date-wrapper').forEach(el => el.remove());

    // Attempt injection with escalating retries
    if (isSearchPage()) {
      attemptInjection();
    }
  }

  /**
   * Attempts to inject the UI with retries.
   * Uses both polling and waitForElement for maximum reliability.
   */
  async function attemptInjection() {
    // Quick attempt first
    if (injectUI()) return;
    // Wait for YouTube's custom elements to be defined
    try {
      await waitForYouTubeElements();
    } catch (e) {
      console.warn('[YT-SortDate] Custom elements not defined, continuing anyway');
    }
    // Wait for the actual search header to appear in the DOM
    try {
      await waitForElement('ytd-search-header-renderer, ytd-search #header, ytd-search', 10000);
    } catch (e) {
      console.warn('[YT-SortDate] Search header element did not appear:', e.message);
    }
    // Final attempts with small delays to let Polymer finish rendering
    for (const delay of [0, 200, 500, 1000, 2000, 4000]) {
      if (delay > 0) await new Promise(r => setTimeout(r, delay));
      if (!isSearchPage()) return; // user navigated away
      if (injectUI()) return;
    }
    console.warn('[YT-SortDate] All injection attempts failed for URL:', window.location.href);
  }
  // ─── INIT ─────────────────────────────────────────────────────────
  function init() {
    console.log('[YT-SortDate] init() called, readyState:', document.readyState, 'URL:', window.location.href);
    injectStyles();
    // Listen for YouTube SPA navigations (both events for maximum coverage)
    window.addEventListener('yt-navigate-finish', onNavigate);
    window.addEventListener('yt-page-data-updated', () => {
      // yt-page-data-updated fires when YouTube has finished loading page data,
      // which is more reliable than yt-navigate-finish on some browser configs
      if (isSearchPage() && !document.querySelector('.yt-sort-date-wrapper')) {
        attemptInjection();
      }
    });
    // Persistent MutationObserver as safety net
    // Watches for DOM changes and re-injects if the button was removed
    // (YouTube's framework can replace DOM subtrees during re-renders)
    const startObserver = () => {
      const observeTarget = document.querySelector('ytd-app') || document.body;
      if (!observeTarget) {
        // Body doesn't exist yet (document-start edge case), retry
        setTimeout(startObserver, 100);
        return;
      }
      const observer = new MutationObserver(() => {
        if (isSearchPage() && !document.querySelector('.yt-sort-date-wrapper')) {
          injectUI(); // quick synchronous attempt; full retry happens via navigation events
        }
      });
      observer.observe(observeTarget, { childList: true, subtree: true });
    };
    startObserver();
    // Tampermonkey menu command
    GM_registerMenuCommand('\\u2699\\uFE0F Sort by Date Settings', () => {
      if (!isSearchPage()) {
        alert('Navigate to a YouTube search page first, then use this menu.');
        return;
      }
      const wrapper = document.querySelector('.yt-sort-date-wrapper');
      if (wrapper) {
        wrapper.querySelector('.yt-sort-date-settings-panel')?.classList.toggle('open');
      } else {
        // Button isn't injected yet — try to inject it now
        attemptInjection().then(() => {
          const w = document.querySelector('.yt-sort-date-wrapper');
          if (w) {
            w.querySelector('.yt-sort-date-settings-panel')?.classList.toggle('open');
          } else {
            alert('Could not inject the Sort by Date button. YouTube may have changed their page structure.\\n\\nPlease report this issue with your browser version and any console errors.');
          }
        });
      }
    });
    // Initial injection attempt
    if (isSearchPage()) {
      attemptInjection();
    }
  }
  // Start — using @run-at document-idle means the DOM is ready
  // but YouTube's custom elements may still be loading
  init();
})();