YouTube Sort by Upload Date

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         YouTube Sort by Upload Date
// @namespace    https://greasyfork.org/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();
})();